From 44ba3d701e7ca10a8dbd42b9f03b7213f0d7a574 Mon Sep 17 00:00:00 2001 From: ColinLi98 <111134421+ColinLi98@users.noreply.github.com> Date: Sun, 10 May 2026 19:01:27 +0100 Subject: [PATCH 1/5] Upgrade Agent Studio workbench --- .github/pull_request_template.md | 5 + .github/workflows/frontend-shell-smoke.yml | 249 + README.md | 359 +- db/migrations/0013_author_work_branches.sql | 34 + db/postgres_schema.sql | 1451 +++ docs/agent_studio_interactive_workbench.md | 111 + .../frontend_reader_shell_state_contract.json | 198 + docs/frontend_reader_shell_state_contract.md | 82 + docs/frontend_shell_rebuild.md | 437 + .../03_CODEX_PR_REVIEW_TEMPLATE.md | 9 + scripts/check_database_env.py | 76 + scripts/run_agent_studio_local.sh | 61 + scripts/run_agent_studio_smoke.sh | 150 + scripts/run_backend_local.sh | 37 + scripts/run_frontend_shell_smoke.sh | 150 + scripts/run_reader_shell_smoke.sh | 145 + scripts/verify_agent_studio_smoke.js | 955 ++ scripts/verify_frontend_shell_smoke.js | 2036 ++++ .../write_agent_studio_smoke_step_summary.py | 176 + ...write_frontend_shell_smoke_step_summary.py | 149 + src/narrativeos/api.py | 142 +- src/narrativeos/api/__init__.py | 23 +- src/narrativeos/api/app_factory.py | 843 +- src/narrativeos/api/auth.py | 449 +- src/narrativeos/api/author.py | 1278 ++- src/narrativeos/api/billing_provider.py | 25 + src/narrativeos/api/customer.py | 375 + src/narrativeos/api/ops.py | 1706 +++- src/narrativeos/api/quantum_compat.py | 5124 ++++++++++ src/narrativeos/api/reader.py | 511 +- src/narrativeos/api/reader_access.py | 70 + .../content_quality_contract_gate.py | 78 + src/narrativeos/benchmark/merge_gate.py | 31 + .../benchmark/release_quality_gate.py | 320 + src/narrativeos/benchmark/reporting.py | 2638 ++++- src/narrativeos/benchmark/runner.py | 2368 ++++- src/narrativeos/canon.py | 55 +- src/narrativeos/commercialization/__init__.py | 17 + src/narrativeos/commercialization/config.py | 74 + src/narrativeos/commercialization/models.py | 151 + src/narrativeos/content_quality_contracts.py | 1051 ++ .../content_quality_strategy_bundles.py | 311 + .../content_quality_strategy_execution.py | 450 + src/narrativeos/core/dialogue.py | 402 +- src/narrativeos/core/emotion_actions.py | 138 +- src/narrativeos/core/linter.py | 2 +- src/narrativeos/core/quality_pass.py | 5362 ++++++++++- src/narrativeos/core/scene_realizer.py | 195 +- src/narrativeos/core/sensory_grounding.py | 181 +- src/narrativeos/core/writer.py | 35 +- src/narrativeos/eval/learned_cadence.py | 5 +- src/narrativeos/eval/scorers.py | 95 +- src/narrativeos/eval/service.py | 264 +- src/narrativeos/eval/validators.py | 126 +- src/narrativeos/long_route_quality.py | 456 + src/narrativeos/longform.py | 1287 +++ src/narrativeos/models.py | 105 +- src/narrativeos/persistence/db.py | 1934 +++- src/narrativeos/persistence/migrations.py | 5 +- src/narrativeos/persistence/preflight.py | 177 + src/narrativeos/persistence/repositories.py | 8446 +++++++++++++++-- src/narrativeos/pipeline.py | 439 +- src/narrativeos/presenter.py | 55 +- src/narrativeos/prompts.py | 18 +- src/narrativeos/prose_linter.py | 79 +- src/narrativeos/providers.py | 639 +- src/narrativeos/quality/__init__.py | 60 + src/narrativeos/quality/adapter.py | 419 + src/narrativeos/quality/config.py | 213 + src/narrativeos/quality/grounding.py | 275 + src/narrativeos/quality/hard_constraints.py | 582 ++ src/narrativeos/quality/models.py | 343 + src/narrativeos/rendering.py | 466 +- src/narrativeos/repetition_detector.py | 539 +- src/narrativeos/runtime_env.py | 62 + src/narrativeos/sanitizer.py | 196 +- src/narrativeos/scoring.py | 270 +- src/narrativeos/search.py | 23 + src/narrativeos/services/analytics.py | 17 +- src/narrativeos/services/async_jobs.py | 9 + src/narrativeos/services/auth.py | 1420 ++- .../services/author_permissions.py | 177 + .../services/author_project_graph.py | 374 + src/narrativeos/services/author_work.py | 1311 +++ src/narrativeos/services/authoring.py | 6059 +++++++++++- src/narrativeos/services/billing.py | 1156 ++- src/narrativeos/services/choice_semantics.py | 167 + src/narrativeos/services/commercial_audit.py | 194 + .../services/commercial_billing.py | 464 + .../services/commercial_delivery_bundle.py | 202 + .../commercial_lifecycle_automation.py | 266 + .../services/commercial_support.py | 393 + .../services/commercialization_uat.py | 545 ++ src/narrativeos/services/customer_accounts.py | 209 + .../services/customer_campaigns.py | 312 + .../services/customer_success_reporting.py | 389 + .../services/customer_workspace.py | 317 + src/narrativeos/services/emailing.py | 497 + .../services/go_live_day_runner.py | 272 + src/narrativeos/services/governance.py | 1920 +++- .../services/human_signoff_closure.py | 638 ++ src/narrativeos/services/illustration.py | 1125 +++ .../services/launch_command_center.py | 115 + .../services/launch_execution_prep.py | 417 + src/narrativeos/services/launch_week_guard.py | 234 + .../services/launch_week_monitoring.py | 726 ++ .../services/library_stats_cube.py | 102 + .../services/library_stats_cube_projection.py | 38 + .../services/library_stats_semantic_layer.py | 149 + .../services/longform_capability.py | 403 + src/narrativeos/services/monetization.py | 760 +- src/narrativeos/services/observability.py | 150 +- .../services/ops_account_workspace.py | 18 + src/narrativeos/services/ops_alerting.py | 45 +- .../ops_commercialization_dashboard.py | 507 + src/narrativeos/services/ops_permissions.py | 135 + .../services/ops_quality_projection.py | 472 + .../services/ops_release_workspace.py | 245 +- src/narrativeos/services/ops_review_hub.py | 1220 +++ src/narrativeos/services/ops_traceability.py | 150 +- .../services/paid_pilot_acceptance.py | 1153 +++ src/narrativeos/services/partner_readiness.py | 174 + .../services/production_acceptance.py | 320 + .../services/production_cutover_checks.py | 394 + .../services/production_go_live_checklist.py | 435 + .../services/production_handshake_pack.py | 620 ++ .../services/production_launch_ledger.py | 286 + .../services/production_launch_week_pack.py | 465 + .../services/production_manual_signoff.py | 169 + .../services/production_preflight.py | 241 + .../services/production_signoff.py | 481 + .../services/production_signoff_board.py | 132 + .../services/quantum_read_models.py | 1207 +++ .../services/reader_generation_jobs.py | 202 + .../reader_illustration_acceptance.py | 616 ++ .../reader_storybook_title_homogenization.py | 331 + src/narrativeos/services/review.py | 1148 ++- src/narrativeos/services/runtime_ops.py | 128 + src/narrativeos/services/sessions.py | 621 +- .../showcase_soul_surface_contract.py | 9 + .../soul_profile_aggregate_contract.py | 22 + src/narrativeos/services/stripe_invoicing.py | 363 + src/narrativeos/services/training_signal.py | 501 +- .../services/wave_activation_controller.py | 329 + src/narrativeos/web/agent_studio.js | 539 ++ src/narrativeos/web/agent_studio_dom.js | 36 + src/narrativeos/web/app.js | 4457 --------- src/narrativeos/web/author_accessors.js | 100 + src/narrativeos/web/author_dom.js | 193 + src/narrativeos/web/author_workspace.js | 7987 ++++++++++++++++ src/narrativeos/web/customer_dom.js | 50 + src/narrativeos/web/customer_workspace.js | 644 ++ src/narrativeos/web/dom_shared.js | 45 + src/narrativeos/web/index.html | 2055 ++-- src/narrativeos/web/ops_accessors.js | 16 + src/narrativeos/web/ops_actions.js | 987 +- src/narrativeos/web/ops_dom.js | 241 + src/narrativeos/web/ops_refresh.js | 537 +- src/narrativeos/web/ops_render_sections.js | 3480 ++++--- src/narrativeos/web/ops_runtime.js | 396 + src/narrativeos/web/ops_shared.js | 367 + src/narrativeos/web/reader.js | 2244 +++++ src/narrativeos/web/reader_accessors.js | 82 + src/narrativeos/web/reader_dom.js | 112 + src/narrativeos/web/reader_shell_v2.js | 1332 +++ src/narrativeos/web/reader_shell_v2_dom.js | 9 + src/narrativeos/web/route_sync_runtime.js | 217 + .../web/shell_bootstrap_runtime.js | 260 + src/narrativeos/web/shell_dom.js | 43 + src/narrativeos/web/shell_runtime.js | 726 ++ src/narrativeos/web/shell_status_runtime.js | 464 + src/narrativeos/web/state_runtime.js | 213 + src/narrativeos/web/styles.css | 4110 ++++++-- src/narrativeos/web/ui_shared.js | 467 + .../web/workspace_layout_runtime.js | 262 + src/narrativeos/worldpacks/models.py | 239 +- src/narrativeos/worldpacks/registry.py | 383 +- src/narrativeos/worldpacks/validator.py | 6 + ...test_agent_studio_interactive_workbench.py | 239 + tests/test_author_workflow.py | 238 +- tests/test_author_works.py | 2048 ++++ tests/test_frontend_shell_docs.py | 205 + tests/test_frontend_shell_smoke_ci.py | 953 ++ tests/test_reader_shell_flow.py | 213 + tests/test_reader_shell_v2.py | 139 + tests/test_vercel_frontend_shell.py | 48 + 186 files changed, 112047 insertions(+), 8854 deletions(-) create mode 100644 .github/workflows/frontend-shell-smoke.yml create mode 100644 db/migrations/0013_author_work_branches.sql create mode 100644 docs/agent_studio_interactive_workbench.md create mode 100644 docs/frontend_reader_shell_state_contract.json create mode 100644 docs/frontend_reader_shell_state_contract.md create mode 100644 docs/frontend_shell_rebuild.md create mode 100755 scripts/check_database_env.py create mode 100755 scripts/run_agent_studio_local.sh create mode 100755 scripts/run_agent_studio_smoke.sh create mode 100755 scripts/run_backend_local.sh create mode 100755 scripts/run_frontend_shell_smoke.sh create mode 100755 scripts/run_reader_shell_smoke.sh create mode 100644 scripts/verify_agent_studio_smoke.js create mode 100644 scripts/verify_frontend_shell_smoke.js create mode 100644 scripts/write_agent_studio_smoke_step_summary.py create mode 100644 scripts/write_frontend_shell_smoke_step_summary.py create mode 100644 src/narrativeos/api/billing_provider.py create mode 100644 src/narrativeos/api/customer.py create mode 100644 src/narrativeos/api/quantum_compat.py create mode 100644 src/narrativeos/api/reader_access.py create mode 100644 src/narrativeos/benchmark/content_quality_contract_gate.py create mode 100644 src/narrativeos/benchmark/release_quality_gate.py create mode 100644 src/narrativeos/commercialization/__init__.py create mode 100644 src/narrativeos/commercialization/config.py create mode 100644 src/narrativeos/commercialization/models.py create mode 100644 src/narrativeos/content_quality_contracts.py create mode 100644 src/narrativeos/content_quality_strategy_bundles.py create mode 100644 src/narrativeos/content_quality_strategy_execution.py create mode 100644 src/narrativeos/long_route_quality.py create mode 100644 src/narrativeos/longform.py create mode 100644 src/narrativeos/persistence/preflight.py create mode 100644 src/narrativeos/quality/__init__.py create mode 100644 src/narrativeos/quality/adapter.py create mode 100644 src/narrativeos/quality/config.py create mode 100644 src/narrativeos/quality/grounding.py create mode 100644 src/narrativeos/quality/hard_constraints.py create mode 100644 src/narrativeos/quality/models.py create mode 100644 src/narrativeos/runtime_env.py create mode 100644 src/narrativeos/services/author_permissions.py create mode 100644 src/narrativeos/services/author_project_graph.py create mode 100644 src/narrativeos/services/author_work.py create mode 100644 src/narrativeos/services/choice_semantics.py create mode 100644 src/narrativeos/services/commercial_audit.py create mode 100644 src/narrativeos/services/commercial_billing.py create mode 100644 src/narrativeos/services/commercial_delivery_bundle.py create mode 100644 src/narrativeos/services/commercial_lifecycle_automation.py create mode 100644 src/narrativeos/services/commercial_support.py create mode 100644 src/narrativeos/services/commercialization_uat.py create mode 100644 src/narrativeos/services/customer_accounts.py create mode 100644 src/narrativeos/services/customer_campaigns.py create mode 100644 src/narrativeos/services/customer_success_reporting.py create mode 100644 src/narrativeos/services/customer_workspace.py create mode 100644 src/narrativeos/services/emailing.py create mode 100644 src/narrativeos/services/go_live_day_runner.py create mode 100644 src/narrativeos/services/human_signoff_closure.py create mode 100644 src/narrativeos/services/illustration.py create mode 100644 src/narrativeos/services/launch_command_center.py create mode 100644 src/narrativeos/services/launch_execution_prep.py create mode 100644 src/narrativeos/services/launch_week_guard.py create mode 100644 src/narrativeos/services/launch_week_monitoring.py create mode 100644 src/narrativeos/services/library_stats_cube.py create mode 100644 src/narrativeos/services/library_stats_cube_projection.py create mode 100644 src/narrativeos/services/library_stats_semantic_layer.py create mode 100644 src/narrativeos/services/longform_capability.py create mode 100644 src/narrativeos/services/ops_commercialization_dashboard.py create mode 100644 src/narrativeos/services/ops_permissions.py create mode 100644 src/narrativeos/services/ops_quality_projection.py create mode 100644 src/narrativeos/services/ops_review_hub.py create mode 100644 src/narrativeos/services/paid_pilot_acceptance.py create mode 100644 src/narrativeos/services/partner_readiness.py create mode 100644 src/narrativeos/services/production_acceptance.py create mode 100644 src/narrativeos/services/production_cutover_checks.py create mode 100644 src/narrativeos/services/production_go_live_checklist.py create mode 100644 src/narrativeos/services/production_handshake_pack.py create mode 100644 src/narrativeos/services/production_launch_ledger.py create mode 100644 src/narrativeos/services/production_launch_week_pack.py create mode 100644 src/narrativeos/services/production_manual_signoff.py create mode 100644 src/narrativeos/services/production_preflight.py create mode 100644 src/narrativeos/services/production_signoff.py create mode 100644 src/narrativeos/services/production_signoff_board.py create mode 100644 src/narrativeos/services/quantum_read_models.py create mode 100644 src/narrativeos/services/reader_generation_jobs.py create mode 100644 src/narrativeos/services/reader_illustration_acceptance.py create mode 100644 src/narrativeos/services/reader_storybook_title_homogenization.py create mode 100644 src/narrativeos/services/showcase_soul_surface_contract.py create mode 100644 src/narrativeos/services/soul_profile_aggregate_contract.py create mode 100644 src/narrativeos/services/stripe_invoicing.py create mode 100644 src/narrativeos/services/wave_activation_controller.py create mode 100644 src/narrativeos/web/agent_studio.js create mode 100644 src/narrativeos/web/agent_studio_dom.js delete mode 100644 src/narrativeos/web/app.js create mode 100644 src/narrativeos/web/author_accessors.js create mode 100644 src/narrativeos/web/author_dom.js create mode 100644 src/narrativeos/web/author_workspace.js create mode 100644 src/narrativeos/web/customer_dom.js create mode 100644 src/narrativeos/web/customer_workspace.js create mode 100644 src/narrativeos/web/dom_shared.js create mode 100644 src/narrativeos/web/ops_accessors.js create mode 100644 src/narrativeos/web/ops_dom.js create mode 100644 src/narrativeos/web/ops_runtime.js create mode 100644 src/narrativeos/web/ops_shared.js create mode 100644 src/narrativeos/web/reader.js create mode 100644 src/narrativeos/web/reader_accessors.js create mode 100644 src/narrativeos/web/reader_dom.js create mode 100644 src/narrativeos/web/reader_shell_v2.js create mode 100644 src/narrativeos/web/reader_shell_v2_dom.js create mode 100644 src/narrativeos/web/route_sync_runtime.js create mode 100644 src/narrativeos/web/shell_bootstrap_runtime.js create mode 100644 src/narrativeos/web/shell_dom.js create mode 100644 src/narrativeos/web/shell_runtime.js create mode 100644 src/narrativeos/web/shell_status_runtime.js create mode 100644 src/narrativeos/web/state_runtime.js create mode 100644 src/narrativeos/web/ui_shared.js create mode 100644 src/narrativeos/web/workspace_layout_runtime.js create mode 100644 tests/test_agent_studio_interactive_workbench.py create mode 100644 tests/test_author_works.py create mode 100644 tests/test_frontend_shell_docs.py create mode 100644 tests/test_frontend_shell_smoke_ci.py create mode 100644 tests/test_reader_shell_flow.py create mode 100644 tests/test_reader_shell_v2.py create mode 100644 tests/test_vercel_frontend_shell.py diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 900549f..3a90bd0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -12,6 +12,11 @@ - weakest pack delta: - cross-pack pass-rate delta: - issue category delta (Q03/Q04/Q05/Q09 if relevant): +- Agent Studio layout CSS touched: [ ] yes / [ ] no +- Agent Studio visual review: + - If yes, this PR changes Agent Studio layout-facing CSS, especially `src/narrativeos/web/styles.css` selectors for `.agent-studio-*`. + - Paste the two `manual_review` rows from `artifacts/agent_studio_smoke_visual_review.md` into a PR comment, then mark each row `accepted` or `needs follow-up`. + - Expected rows: `desktop / Three-column workbench review / manual_review` and `mobile / Stacked workbench review / manual_review`. - rollback point: - next suggested task: diff --git a/.github/workflows/frontend-shell-smoke.yml b/.github/workflows/frontend-shell-smoke.yml new file mode 100644 index 0000000..7e7713e --- /dev/null +++ b/.github/workflows/frontend-shell-smoke.yml @@ -0,0 +1,249 @@ +name: frontend-shell-smoke + +on: + push: + pull_request: + +jobs: + smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - uses: actions/setup-node@v4 + with: + node-version: "22" + - uses: browser-actions/setup-chrome@v1 + id: setup-chrome + - name: Install deps + run: | + python -m venv .venv + . .venv/bin/activate + pip install -r requirements.txt + - name: Run frontend shell smoke + run: | + CI_HEADLESS=1 \ + CHROME_BIN="${{ steps.setup-chrome.outputs.chrome-path }}" \ + bash scripts/run_frontend_shell_smoke.sh + - name: Publish frontend shell smoke summary + if: always() + run: | + . .venv/bin/activate + python scripts/write_frontend_shell_smoke_step_summary.py \ + --result-file artifacts/frontend_shell_smoke_result.json \ + --server-log /tmp/frontend_shell_smoke_server.log \ + --chrome-log /tmp/frontend_shell_smoke_chrome.log \ + --failure-artifact artifacts/frontend_shell_smoke_failure_snapshot.json \ + >> "$GITHUB_STEP_SUMMARY" + - name: Upload frontend shell smoke artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: frontend-shell-smoke-artifacts + if-no-files-found: warn + path: | + artifacts/frontend_shell_smoke_result.json + artifacts/frontend_shell_smoke_failure_snapshot.json + artifacts/frontend_shell_smoke_failure.png + /tmp/frontend_shell_smoke_server.log + /tmp/frontend_shell_smoke_chrome.log + + agent-studio-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - uses: actions/setup-node@v4 + with: + node-version: "22" + - uses: browser-actions/setup-chrome@v1 + id: setup-chrome + - name: Install deps + run: | + python -m venv .venv + . .venv/bin/activate + pip install -r requirements.txt + - name: Run Agent Studio smoke + run: | + CI_HEADLESS=1 \ + CHROME_BIN="${{ steps.setup-chrome.outputs.chrome-path }}" \ + bash scripts/run_agent_studio_smoke.sh + - name: Publish Agent Studio smoke summary + if: always() + run: | + . .venv/bin/activate + python scripts/write_agent_studio_smoke_step_summary.py \ + --result-file artifacts/agent_studio_smoke_result.json \ + --server-log /tmp/agent_studio_smoke_server.log \ + --chrome-log /tmp/agent_studio_smoke_chrome.log \ + --failure-artifact artifacts/agent_studio_smoke_failure_snapshot.json \ + >> "$GITHUB_STEP_SUMMARY" + - name: Upload Agent Studio smoke artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: agent-studio-smoke-artifacts + if-no-files-found: warn + path: | + artifacts/agent_studio_smoke_result.json + artifacts/agent_studio_smoke_failure_snapshot.json + artifacts/agent_studio_smoke_failure.png + artifacts/agent_studio_smoke_desktop.png + artifacts/agent_studio_smoke_mobile.png + artifacts/agent_studio_smoke_visual_review.md + /tmp/agent_studio_smoke_server.log + /tmp/agent_studio_smoke_chrome.log + + quantum-ops-url-state: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - uses: actions/setup-node@v4 + with: + node-version: "22" + - uses: browser-actions/setup-chrome@v1 + id: setup-chrome + - name: Install deps + run: | + python -m venv .venv + . .venv/bin/activate + pip install -r requirements.txt + - name: Install Quantum frontend deps + working-directory: Kimi_Agent_设计系统加载/app + run: npm ci + - name: Run Quantum Ops URL-state smoke + run: | + CI_HEADLESS=1 \ + CHROME_BIN="${{ steps.setup-chrome.outputs.chrome-path }}" \ + bash scripts/run_quantum_ops_url_state_smoke.sh + - name: Publish Quantum Ops URL-state summary + if: always() + run: | + . .venv/bin/activate + python scripts/write_quantum_ops_url_state_smoke_step_summary.py \ + --result-file artifacts/quantum_ops_url_state_smoke_result.json \ + --backend-log /tmp/quantum_ops_url_state_backend.log \ + --frontend-log /tmp/quantum_ops_url_state_frontend.log \ + --chrome-log /tmp/quantum_ops_url_state_chrome.log \ + --failure-artifact artifacts/quantum_ops_url_state_smoke_failure_snapshot.json \ + >> "$GITHUB_STEP_SUMMARY" + - name: Upload Quantum Ops URL-state artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: quantum-ops-url-state-artifacts + if-no-files-found: warn + path: | + artifacts/quantum_ops_url_state_smoke_result.json + artifacts/quantum_ops_url_state_smoke_failure_snapshot.json + artifacts/quantum_ops_url_state_smoke_failure.png + /tmp/quantum_ops_url_state_backend.log + /tmp/quantum_ops_url_state_frontend.log + /tmp/quantum_ops_url_state_chrome.log + + quantum-library: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - uses: actions/setup-node@v4 + with: + node-version: "22" + - uses: browser-actions/setup-chrome@v1 + id: setup-chrome + - name: Install deps + run: | + python -m venv .venv + . .venv/bin/activate + pip install -r requirements.txt + - name: Install Quantum frontend deps + working-directory: Kimi_Agent_设计系统加载/app + run: npm ci + - name: Run Quantum library smoke + run: | + CI_HEADLESS=1 \ + CHROME_BIN="${{ steps.setup-chrome.outputs.chrome-path }}" \ + bash scripts/run_quantum_library_smoke.sh + - name: Publish Quantum library smoke summary + if: always() + run: | + . .venv/bin/activate + python scripts/write_quantum_library_smoke_step_summary.py \ + --result-file artifacts/quantum_library_smoke_result.json \ + --backend-log /tmp/quantum_library_backend.log \ + --frontend-log /tmp/quantum_library_frontend.log \ + --chrome-log /tmp/quantum_library_chrome.log \ + --failure-artifact artifacts/quantum_library_smoke_failure_snapshot.json \ + >> "$GITHUB_STEP_SUMMARY" + - name: Upload Quantum library smoke artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: quantum-library-smoke-artifacts + if-no-files-found: warn + path: | + artifacts/quantum_library_smoke_result.json + artifacts/quantum_library_smoke_failure_snapshot.json + artifacts/quantum_library_smoke_failure.png + /tmp/quantum_library_backend.log + /tmp/quantum_library_frontend.log + /tmp/quantum_library_chrome.log + + quantum-author-follow: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - uses: actions/setup-node@v4 + with: + node-version: "22" + - uses: browser-actions/setup-chrome@v1 + id: setup-chrome + - name: Install deps + run: | + python -m venv .venv + . .venv/bin/activate + pip install -r requirements.txt + - name: Install Quantum frontend deps + working-directory: Kimi_Agent_设计系统加载/app + run: npm ci + - name: Run Quantum author follow smoke + run: | + CI_HEADLESS=1 \ + CHROME_BIN="${{ steps.setup-chrome.outputs.chrome-path }}" \ + bash scripts/run_quantum_author_follow_smoke.sh + - name: Publish Quantum author follow summary + if: always() + run: | + . .venv/bin/activate + python scripts/write_quantum_author_follow_smoke_step_summary.py \ + --result-file artifacts/quantum_author_follow_smoke_result.json \ + --backend-log /tmp/quantum_author_follow_backend.log \ + --frontend-log /tmp/quantum_author_follow_frontend.log \ + --chrome-log /tmp/quantum_author_follow_chrome.log \ + --failure-artifact artifacts/quantum_author_follow_smoke_failure_snapshot.json \ + >> "$GITHUB_STEP_SUMMARY" + - name: Upload Quantum author follow artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: quantum-author-follow-smoke-artifacts + if-no-files-found: warn + path: | + artifacts/quantum_author_follow_smoke_result.json + artifacts/quantum_author_follow_smoke_failure_snapshot.json + artifacts/quantum_author_follow_smoke_failure.png + /tmp/quantum_author_follow_backend.log + /tmp/quantum_author_follow_frontend.log + /tmp/quantum_author_follow_chrome.log diff --git a/README.md b/README.md index d056af4..75676ea 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ - [docs/gpt_handoff_status_and_commercialization.md](/Users/lili/Desktop/narrativeos_codex_handoff/docs/gpt_handoff_status_and_commercialization.md) +如果要理解“什么才算最终商业化 v1 完成态 + 接下来按什么顺序做”,同样优先看上面的 canonical handoff 文档;`narrativeos_codex_next_phase/01_90_DAY_EXECUTION_PLAN.md` 与 `07_ACCEPTANCE_METRICS.md` 已对齐这套标准。 + 这套仓库现在已经从“单作品可运行 Alpha”升级为一个 **商业化 Beta 内核雏形**。它仍然保留现有 Alpha 的 `/app`、Reader Mode、示例世界与基础 API,但新增了: - 多 `World Pack` 加载与版本管理 @@ -11,6 +13,118 @@ - Author 端已支持“选题材 + 写 brief + 生成 Draft”的普通用户创作入口 - Author 端现已补强 `draft diff + simulation drill-down` - Author 端现已补 `style / pacing / hook` 结构化控制面板 +- Author 端现已补 `Longform Workbench`: + - 可编辑 `Series / Volume / Arc` + - 历史 draft 可一键 bootstrap longform plan + - 保存仍走同一条 draft update / revision diff / simulation freshness 路径 +- Author 端现已补 `Promise Ledger + Continuity Diff workbench`: + - `Promise Ledger` 会显示 open / overdue / recently closed promises + - `Continuity Diff` 会显示 drifting character / causal break / promise risk / before-after changed chapters + - 两块都能直接 prefill 到现有 comment anchor 流 +- Author 端现已补 `chapter-task editing + arc board`: + - 可直接选中某条 `chapter task` 并改 `duty / objective / target_words / reveal_budget / promise_actions / allow_terminal` + - `Arc Board` 会按卷展示所有 arc,并支持切换当前 arc/task + - 当 `arc.target_chapters` 调整时,task 序列会自动规整长度 +- Author 端现已补 `series-volume-arc promise mapping + chapter-task simulation linking`: + - `Promise Mapping` 会按当前 `series / volume / arc` 聚合显示映射到的 promises 与章节范围 + - `Task Simulation Linking` 会把当前 `chapter task` 直接连到 simulation chapters / issues / promises,方便一跳评论或修订 +- Author 端现已补 `promise-state editing + chapter-level jump navigation`: + - 可给单条 promise 保存 `watch / defer / plan_payoff / resolved_intentional / escalate` 状态与备注 + - 可从 `Promise Ledger / Promise Mapping / Task Linking / Continuity Diff` 一跳定位到对应 simulation chapter,并在作者页高亮当前章节 +- Author 端现已补 `continuity override + task-to-compare deep link workflow`: + - 可对单章保存 `watch / intentional / accepted_tradeoff / needs_rewrite / escalate` 的 continuity override 与 issue scope + - 可从 `chapter task` 直接跳到该章的 `before / after compare`,并在 compare 面板展开对应章节对照 +- Author 端现已补 `arc-board drag reorder + task-level compare diff`: + - `Arc Board` 支持同卷 arc 的拖拽重排,保存仍沿用现有 longform draft update 路径 + - `Task Compare Diff` 会把单条 `chapter task` 对应到章节对照,汇总 score delta 与 issue added/removed +- Author 端现已补 `chapter-task drag reorder + task-to-simulation bulk apply`: + - 当前 arc 下的 `chapter_tasks` 现支持拖拽重排 + - 可把一条 task 的 continuity judgment 批量应用到它链接到的 simulation chapters +- Author 端现已补 `task-level promise split-merge + planned-vs-observed drift panel`: + - `promise_targets` 支持 `Split Targets / Merge Observed Promises` + - `Task Linking` 会直接显示 `planned vs observed promise drift`,对比计划目标和 simulation 实际命中 +- Author 端现已补 `task-level promise drift remediation suggestions + compare-to-rewrite workflow`: + - `Task Linking` 会给出 drift remediation suggestions + - 并支持 `Apply Compare to Rewrite`,把章节对照里的 rewrite 提示直接预填回当前 task 编辑器 +- Author 端现已补 `rewrite patch preview + simulation diff checkpoint`: + - `Rewrite Patch Preview` 会直接对比当前表单值和 rewrite suggestion + - `Simulation Diff Checkpoint` 会显示最近一次 rewrite revision 是否已经产出对应 simulation diff +- Author 端现已补 `作品阅读预览`: + - 在创作台里直接用阅读卡片方式查看当前选中章节正文 + - 左侧切章、右侧编辑、下方阅读预览,减少作者在“写”和“读自己作品”之间来回切模式 +- `/app/user` 的 Reader 书架现也补了 `我的作品` 入口: + - 登录作者账号后,会在阅读首页直接列出自己已生成章节的 `author_work` + - 可一键“像读者一样阅读”,在 Reader 视图里按章节顺序浏览自己的作品 +- persisted chapter 现已开始接入统一 `chapter_quality_guard`: + - Author work 生成 / 手工保存 / Reader session 继续推进都会在落库前执行同一套章节硬约束 + - 失败时返回 `chapter_quality_guard_failed` + - 不再允许过短或明确 `rewrite/block` 的章节直接成为 canonical persisted content +- `interactive 100章` 基础 contract 现已开始接入: + - Reader session 支持 `longform_setup` + - Reader continue 支持 `steering_directive` + - runtime state 支持 `steering_ledger / storyline_checkpoint / character_memory_runtime / replan_checkpoint` + - benchmark 新增 `longform_100_interactive` + - review / merge gate 现已识别 `interactive_longform_signoff` +- Author 长线入口现已明确分层: + - `from-brief` 属于 `quick_brief`,默认只直接承诺到 `100章` + - `250 / 500 / 1000` 现在统一归到 `structured longform` gated capability + - `1000章` 仍然保留,但产品口径改成“结构化长篇能力 + readiness contract”,不再等价于任意 brief 一键直达 +- Ops `release evidence bundle / publish checklist` 现也已接 Author 长线口径: + - 会直接显示 `author_longform_capability / author_claim_alignment` + - 如果 author `claim_safe_band` 超过 ops 当前真实可发布 band,会被直接视为 publish blocker +- `250章 static` 证据线现已开始接入: + - runtime 支持 `memory_compression_policy / volume_memory_snapshots / replan_history / replan_stability_metrics` + - benchmark 新增 `longform_250` + - `volume-boundary` 现在会在卷末终章补齐 final volume snapshot,不再只依赖下一卷切换 + - `250 review sampling` 现支持从 benchmark chapter reports materialize `evaluation_report_auto` 样本并计算 coverage closeout + - publish checklist 会显示 `longform_250_readiness`,但当前仍是非阻断 evidence + - fresh `all-pack longform_250 --execute-review-sampling-250` 现已达到 `longform_250_signoff = ready` + - fresh `all-pack longform_250_interactive --execute-review-sampling-250` 现已达到 `longform_250_interactive_signoff = ready` + - `250 human-reviewed closeout` 现已单独建模: + - `review_sample_coverage_250` 会区分 `auto-seeded closeout` 和 `human closeout` + - benchmark/reporting 现会输出 `longform_250_human_review_closeout` + - Ops 现可通过 `GET /v1/ops/longform-250-human-review-closeout` 查看还差哪些章节的人审 +- `500章 static` 基础 contract 现已开始接入: + - runtime state 新增 `series_memory_snapshots / series_ending_checkpoint` + - `memory_compression_policy` 现支持 series-level snapshot cadence 与 ending activation window + - benchmark 新增 `longform_500` + - publish checklist 现会显示 `longform_500_readiness`,仍为非阻断 evidence + - `500 human-reviewed closeout` 现也已单独建模: + - `review_sample_coverage_500` 会区分 `auto-seeded closeout`、`human closeout` 和 `ending window human closeout` + - benchmark/reporting 现会输出 `longform_500_human_review_closeout` 与 `longform_500_ending_signoff` + - Ops 现可通过 `GET /v1/ops/longform-500-human-review-closeout` 查看 500 章窗口和终局窗口仍待人审的 target + - `500 interactive` 现也已接入: + - benchmark 新增 `longform_500_interactive` + - reporting 会输出 `longform_500_interactive_summary / longform_500_interactive_signoff` + - publish checklist 会显示 `interactive_500_readiness` + - `500 release evidence bundle` 现已接入: + - 会把 `static / interactive / human closeout / ending signoff` 收成单个 release bundle + - publish checklist 现会显示 `longform_500_release_bundle` + - Ops 可通过 `GET /v1/ops/worlds/{world_id}/release-evidence-bundle` 直接拉取 bundle +- `1000章 feasibility diagnostics` 现已接入: + - benchmark 新增 `longform_1000_diagnostics` + - reporting 现会输出 `longform_1000_summary / longform_1000_feasibility / longform_1000_readiness` + - 指标覆盖 `series snapshot integrity / archive retention / continuation retention / late-stage runtime pressure / ending runway` + - `1000 readiness contract` 现已接上: + - benchmark 新增 `longform_1000_interactive` + - reporting 会输出 `longform_1000_interactive_summary / longform_1000_interactive_signoff / longform_1000_human_review_closeout` + - publish checklist 现会显示 `longform_1000_readiness / interactive_1000_readiness / longform_1000_human_review_closeout / longform_1000_release_bundle` + - Ops 现支持 `GET /v1/ops/longform-1000-human-review-closeout` + - generic release evidence endpoint 会在 `1000` 合同时优先返回 `1000` bundle + - `longform_1000_feasibility` 仍是非阻断 evidence;`longform_1000_readiness` 是其上的 readiness contract + - 当前已确认: + - `1000` 的 global `series snapshot cadence` 修复后,fresh all-pack diagnostics 已可达到 `diagnostic_pass_rate = 1.0` + - `longform_1000_feasibility` 现已达到 `promising` + - `jade_court_romance` 的 late-stage planner trace 已下钻到 beat 级,并给出 `evaluate_candidates` 的 `provider / critics / scoring / sort / total` cost split + - provider-side candidate generation 已补 `cache/reuse + continuation template memoization`,fresh sequential all-pack probes 现已能全部收口 + - `Q06 / character_fidelity` remediation framework 现已接入: + - benchmark/reporting 会聚合 `Q06` 世界、角色热点、task duty 热点与推荐资产焦点 + - authoring draft detail 会返回 `character_fidelity_remediation_framework` + - publish checklist 现会显示 `q06_character_fidelity_framework` + - `Q06` 的执行层修复也已接上: + - planner scoring 会把 `character_card_alignment / duty_alignment / emotion_action_alignment` 折进候选打分 + - emotion/action fallback defaults 现在会读取 actor pressure style 与 `chapter_task.duty_type` + - 这让 `1000` 诊断从“知道哪里失真”推进到“开始对 route 选择和默认表现做实质约束” - Ops 端已支持 review history、publish checklist、rollback history、quality trend 与风险摘要 - Ops 端现已补 `Alert Center`,能把 runtime / support / governance / async job 信号聚成主动告警 feed,并支持 acknowledge / resolve / investigation prefill - Ops 端现已补 `Ops Control Plane`,把 `Alert Center + Account Workspace + Release Workspace + Governance + Investigation` 串到同一套 navigation / escalation model @@ -30,6 +144,48 @@ - `quality_trend_summary` - Reader 端已支持内测 entitlement / credits 授予、`active / expired / exhausted` 状态与访问原因展示 - Monetization & Entitlements M0 已开始落地:3 档会员、双钱包、subscription lifecycle、web-first checkout stub +- Reader 端现已补用户注册 / 登录 UI,并直接挂到 `/v1/auth register/login/me/logout` +- Billing provider 现支持 `stripe`: + - `NARRATIVEOS_BILLING_PROVIDER=stripe` + - `NARRATIVEOS_STRIPE_SECRET_KEY` + - `NARRATIVEOS_STRIPE_PUBLISHABLE_KEY` + - `NARRATIVEOS_STRIPE_WEBHOOK_SECRET` + - `NARRATIVEOS_STRIPE_PRICE_MAP_JSON` + - `NARRATIVEOS_STRIPE_INK_PRICE_MAP_JSON` + - `NARRATIVEOS_APP_BASE_URL` +- Reader Checkout 现会在 provider 为 `stripe` 时直接打开 Stripe hosted checkout +- Stripe hosted checkout 的 success 回跳现使用 `checkout_session_id`,避免和 Reader 故事 `session_id` 路由冲突 +- Reader 现支持在 success 回跳后主动调用 checkout completion reconcile: + - 即使 webhook 还没到,本地也能拉 Stripe checkout/subscription/customer 状态完成一次对账 +- Reader 现已补 `Manage Subscription`,在 Stripe customer id 已建立时可直接打开 customer portal +- 已完成一轮真实 Stripe sandbox browser drill: + - 在无 `stripe listen` 自动转发的情况下,hosted checkout 完成后,本地 Reader 已能靠 success return reconcile 收口到 `subscription=active` + - 随后补发 delayed `checkout.session.completed` webhook,本地保持 `active`、customer portal 持续可用,且只保留一条 subscription 记录 +- success return 现也会保留 Reader checkout context: + - 会带回支付前的 `account_id / session_id / reader workspace / active view` + - 不再在回跳后被默认 `reader_demo` 覆盖 + - 若支付前正在某段故事里,回跳后会优先回到原 session 再继续刷新权益 +- 会员系统现已开始统一到 central effective membership: + - Stripe / App Store / Google Play 的 provider-native subscriptions 会单独保留 + - Reader / Author 判权读取统一的 effective tier,而不是只看单一 provider row + - `subscription_status` / account workspace 会返回 `effective_tier / provider_subscriptions / provider_source_summary` +- Auth 现已开始补商业化必要闭环: + - `email_verified / verification_required` 已进入 auth/session payload + - 已补邮箱验证 / 重发验证邮件 / 找回密码 / 重置密码接口 + - 未验证邮箱现在不会再成功登录拿 token + - 浏览器登录态现支持 `bearer + httponly cookie` 双轨 + - sender config 现统一到 `EMAIL_MODE / EMAIL_PROVIDER / RESEND_*` +- 移动端支付现已补 backend-first 接口: + - Apple verify / Google verify / provider-neutral restore + - Apple server notifications / Google RTDN ingestion +- 当前真实收费 release boundary 仍以 `Stripe + Resend` 为准: + - Web charging / portal / webhook / delayed-webhook recovery 已作为主线 + - Apple / Google 仍保留 backend-first ingress,但不是当前 release blocker + - `EMAIL_MODE=test` 下只允许 `@resend.dev` 或 allowlist 测试邮箱 + - `EMAIL_MODE=production` 仅在 `RESEND_VERIFIED_DOMAIN_STATUS=verified` 时允许真实邮箱投递 +- Reader API 现也补了 ownership hardening: + - bearer token 存在时,`Authorization Bearer` 优先于 `account_id / reader_id` + - token 账号与请求中的 `account_id / reader_id` 不一致时会返回 `reader_account_ownership_mismatch` - Phase 3 monetization 现已继续补 webhook / renewal / cancel / retry / past_due 生命周期闭环、checkout session 持久化,以及 Reader / Author / Ops 生命周期可见性 - Phase 4 数据飞轮接口已继续推进:支持 `dataset_view`,可导出 evaluator-ready / reranker-ready / analytics-ready examples,并附带 split 与质量告警 - `Postgres-first + SQLite fallback` 的平台化持久层 @@ -86,7 +242,7 @@ - Reader 在线生成只记录,不阻断 - draft simulation 现已附带 `cross_pack_summary / metric_deltas / top_failing_packs` - publish gate 现会检查 `cross-pack summary / prose leak / metric regression` -- Repository 默认会读取 `DATABASE_URL`;未设置时回退到 `sqlite:///narrativeos_beta.db` +- Repository 现会优先自动读取仓库根目录 `.env.local`、再读 `.env`;若 shell 已显式设置 `DATABASE_URL`,则不会被文件覆盖。若三者都缺失,才回退到 `sqlite:///narrativeos_beta.db` ### 已实现能力 @@ -219,6 +375,7 @@ - Author collaboration 现已继续补 inline thread reply / watcher / inbox filters / bulk notification status / async notification mirror - Author collaboration 现已继续补 header-based identity shim / draft watcher / inbox cursor + search / notification preferences / thread-update throttling - Author collaboration 现已继续补 `/v1/auth register/login/me/logout`、bearer token auth、独立 Notification Settings panel 与 per-user email/slack routing stub +- Reader shell 现也同步补上账号登录态展示,避免再只靠 `reader_id` 手工输入来跑订阅流 - Author API 已补 `brief-template` 与 `drafts/from-brief` - Ops API 已具备 Review Queue / Publish / Rollback / World Status / Meter 查询 - Ops API 已补 `/v1/ops/worlds/{world_id}/history` @@ -244,6 +401,7 @@ - `action_pack` - `investigation_summary` - `operator_timeline` + - 当 `phase_a_quality_gate` 阻断发布时,workspace 会把 gate 的失败 checks 拆成可 drill-down 的 blocker cards,而不只显示 checklist 汇总原因 - `/app` 中新增 `发布 / Checklist / 回滚统一处置页` - operator 现在可以围绕一个 `world_id` 先看: - 能不能发 @@ -492,11 +650,26 @@ - `POST /v1/ops/wallets/debit` - Reader API 已扩展: - `GET /v1/reader/subscription` - - `POST /v1/reader/checkout/start` - - `POST /v1/reader/checkout/webhook` - - `POST /v1/reader/subscription/{account_id}/retry-payment` +- `POST /v1/reader/checkout/start` +- `POST /v1/reader/checkout/{checkout_session_id}/complete` +- `POST /v1/reader/checkout/webhook` +- `POST /v1/reader/checkout/stripe-webhook` +- `POST /v1/reader/mobile-purchases/apple/verify` +- `POST /v1/reader/mobile-purchases/google/verify` +- `POST /v1/reader/mobile-purchases/restore` +- `POST /v1/reader/billing/apple-server-notifications` +- `POST /v1/reader/billing/google-rtdn` +- `POST /v1/reader/subscription/{account_id}/portal` +- `POST /v1/reader/subscription/{account_id}/retry-payment` - `POST /v1/reader/subscription/{account_id}/renew` - `POST /v1/reader/subscription/{account_id}/cancel` +- Auth API 现已扩展: + - `POST /v1/auth/verification/request` + - `POST /v1/auth/verification/confirm` + - `POST /v1/auth/password-reset/request` + - `POST /v1/auth/password-reset/confirm` +- Ops API 现已扩展: + - `POST /v1/ops/accounts/{account_id}/billing/reconcile` - `/app` 的 Ops 区现已补: - `Evaluator Promotion Gate` - `Reranker Promotion Gate` @@ -648,6 +821,39 @@ - orphan route choice detection - duplicate active subscription detection - safe dry-run / apply repair actions +- Longform Program L1 基础现已开始接入: + - `worldpack` 顶层支持 `series_plan / volume_plans / arc_plans / chapter_budget_policy` + - `NarrativeState` 支持 `current_series_id / current_volume_id / current_arc_id / current_chapter_task / word_budget` + - 新增五层长程记忆骨架: + - `canonical_memory` + - `active_arc_memory` + - `promise_ledger` 继续复用 `open_promises` + - `rolling_recap` + - `archive_memory` + - `brief -> draft` 默认会自动生成 `5卷` 长篇规划骨架 + - `volume / arc / chapter task` 现已按 `target_chapters` 进入确定性推进状态机,而不是固定停在首卷首弧 + - simulation report 现已补 `longform_drilldown`,会输出 `volume_progress / arc_progress / duty_histogram / weakest_arcs / gate_failed_checks` + - benchmark 新增 `benchmark_mode=longform_100`,并带真实 `longform_gate` + - `longform_gate` 现会直接纳入 `stop_reason / mid_arc_window / Q09 incidence` evidence,并附带 calibration summary + - 没有 `series_plan` 的历史 world pack 在 longform simulation/benchmark 中也会自动合成 `runtime_fallback` 规划骨架 + - `route survival` 现已不再被 `min_end_turn` 的短路线默认值卡死,continuation candidates 会在 longform 模式下提前启用 + - `anti-loop` 现已把 `arc_task_repeat_rate` 从高重复簇压到接近 `0.0`,mid-arc variation 不再只靠 fallback duty + - `scene-level variation` 现已开始进入 `writer / scene_realizer / sensory / dialogue` 共同变体路径,但 weakest pack 的 `Q03` 仍只得到小幅改善,下一阶段仍需继续压 `voice/detail` 重复 + - `voice/detail asset diversification` 现已通过 runtime asset enrichment 接进 weakest packs: + - 弱 pack 的 `voice_profiles / response_profiles / sensory slots / scene openings/hooks` 会在 runtime 被自动补齐到更高样本深度 + - `urban_mystery_lotus_lane` 与 `synthetic_min_pack` 的 `Q03` 已在 fresh `longform_100` benchmark 中压掉 + - 但 weakest 问题也开始转移到 `Q05 / scene_detail_density` + - `Q05 / scene detail density` 现也补了 writer-side remediation: + - `scene_detail` 会自动补高密度物件/声响尾句 + - `quality_pass` 的 detail reinforcement 变得更强 + - `jade_court_exam / jade_court_romance` 在 fresh `longform_100` benchmark 中已从 `Q05 x10` 回到 `clean` + - `dialogue ratio / scene-density balance` 现已继续补: + - `quality_pass` 会在低 `dialogue_plus_action_ratio` 章节中自动插入更强的动作-对白推进段 + - action marker 也扩到当前 writer 常用动词,避免真实动作被低估 + - weakest packs 的 `dialogue_ratio` 在 fresh `longform_100` benchmark 中已从约 `0.36` 抬到 `0.60+` + - benchmark 现也会输出 `weakest_pack_polish_program`: + - 每个 weakest pack 都带 `stop_condition + polish_bundle` + - 能直接判断当前 weakest-pack polish 是该继续,还是已经可以 `stop_ready` - runtime ops / runbook 现已补: - sqlite backup / restore - deployment runbook @@ -706,9 +912,34 @@ python -m src.narrativeos.demo ```bash source .venv/bin/activate +python scripts/check_database_env.py --format text uvicorn src.narrativeos.api:app --reload ``` +推荐本地启动脚本: + +```bash +bash scripts/run_backend_local.sh +``` + +Agent Studio 本地创作入口: + +```bash +bash scripts/run_agent_studio_local.sh +``` + +该脚本会启动本地后端,并在健康检查通过后自动打开: + +```text +http://127.0.0.1:8000/app?product=author&workspace=studio&debug=1 +``` + +如果只想启动服务、不自动打开浏览器: + +```bash +AGENT_STUDIO_OPEN_BROWSER=0 bash scripts/run_agent_studio_local.sh +``` + 前端入口: ```text @@ -745,13 +976,34 @@ uvicorn src.narrativeos.api:app --reload ```bash source .venv/bin/activate export NARRATIVEOS_LLM_ROUTING_ENABLED=true -export NARRATIVEOS_LLM_PROVIDER_ORDER=openai,anthropic,local +export NARRATIVEOS_LLM_PROVIDER_ORDER=deepseek,openai,anthropic,local export NARRATIVEOS_LLM_MAX_ATTEMPTS=2 +export DEEPSEEK_API_KEY=... +export NARRATIVEOS_DEEPSEEK_MODEL=deepseek-v4-flash export OPENAI_API_KEY=... export ANTHROPIC_API_KEY=... uvicorn src.narrativeos.api:app --reload ``` +DeepSeek is wired through the same provider boundary and rollout controls as other LLM backends. Keep it in shadow/canary for renderer traffic until cross-pack Q03/Q04/Q05/Q09 and fallback-rate deltas are reviewed; rollback is env-only by removing `deepseek` from provider order or rolling back the renderer track. + +For the Lane A renderer shadow eval, keep candidate generation static and route only the renderer through DeepSeek: + +```bash +export DEEPSEEK_API_KEY="rotated_key_here" +.venv/bin/python scripts/run_deepseek_renderer_shadow_eval.py \ + --model deepseek-v4-flash \ + --model deepseek-v4-pro \ + --worldpack jade_court_romance \ + --worldpack synthetic_min_pack \ + --worldpack urban_mystery_lotus_lane \ + --baseline-file tests/benchmark_baseline.json \ + --max-chapters 6 \ + --output-dir artifacts/lane_a_task_0_3_deepseek_shadow_eval +``` + +The script fails fast if the key is missing, if no runtime receipts are recorded, or if DeepSeek is never selected as renderer. It reports fallback rate, length retry rate, renderer latency, and Q03/Q04/Q05/Q09 deltas for Flash and Pro. + Postgres-first 开发时,请先应用 `db/postgres_schema.sql`,再把仓库层改为对应 DSN。 更完整的 Postgres 初始化方式: @@ -772,6 +1024,17 @@ python -m src.narrativeos.persistence.migrations \ --dry-run ``` +Longform `100章` benchmark 调用方式: + +```bash +source .venv/bin/activate +python -m src.narrativeos.benchmark.runner \ + --worldpack synthetic_min_pack \ + --benchmark-mode longform_100 \ + --max-chapters 100 \ + --database-url sqlite:///narrativeos_beta.db +``` + 查看 Alembic current/head: ```bash @@ -952,6 +1215,49 @@ long-route 模式会额外输出: - continuation candidates 会移除 terminal metadata,并按 phase 轮换 scene function / promise / seed / location - 这样 long-route benchmark 更接近“内容是否还能继续读”,而不是过早停在 `no_legal_routes` +Reader-only smoke(只验证阅读入口与续读 UX): + +```bash +CI_HEADLESS=1 CHROME_BIN=/path/to/google-chrome \ + bash scripts/run_reader_shell_smoke.sh +``` + +产物会写到: + +- `artifacts/reader_shell_smoke_result.json` +- `artifacts/reader_shell_smoke_failure_snapshot.json` +- `artifacts/reader_shell_smoke_failure.png` + +200 章静态 control: + +```bash +source .venv/bin/activate +python -m src.narrativeos.benchmark.runner \ + --worldpack all \ + --database-url sqlite:///artifacts/benchmark_200.db \ + --max-chapters 200 \ + --markdown-out artifacts/benchmark_200.md +``` + +200 章强交互 long-route: + +```bash +source .venv/bin/activate +python -m src.narrativeos.benchmark.runner \ + --worldpack all \ + --database-url sqlite:///artifacts/benchmark_200_interactive.db \ + --benchmark-mode long_route \ + --max-chapters 200 \ + --interactive-profile strong \ + --markdown-out artifacts/benchmark_200_interactive.md +``` + +`--interactive-profile strong` 会在 chapter `20 / 60 / 100 / 140 / 180` 固定注入 steering checkpoints,并在报告里额外输出: + +- `interactive_long_route_summary` +- per-pack `post_steer_issue_window_summary` +- `Q03 / Q04 / Q05 / Q09` 的 `0-3` 章与 `0-10` 章窗口风险 + Q03 / Q04 / Q05 / Q09 remediation framework: - `Q03` @@ -978,12 +1284,18 @@ PR_BODY_FILE=/absolute/path/to/pr-body.md scripts/run_cross_pack_merge_gate.sh merge gate 当前会阻断: +- `configs/release_quality_gate.json` 定义的共享 Phase A 质量门槛未达标: + - `cross_pack_pass_rate` 低于门槛 + - weakest packs 的 `pass_rate` 低于门槛 + - weakest packs 的 `Q03 / Q04 / Q05 / Q09` share 超过门槛 - `cross_pack_pass_rate` 回退 - benchmark `regressions` 非空 - PR 缺少 `strongest pack delta / weakest pack delta / cross-pack pass-rate delta / rollback point` - PR 缺少 `Goal met / Out-of-scope changes introduced / commercialization / kernel-vs-current-pack polish` 等纪律字段 - `Does this improve kernel/product/ops instead of just current-pack polish? = no` +publish checklist 现在也读取同一份 `configs/release_quality_gate.json`,`Phase A 共享质量门槛` 会作为阻断项进入 release workspace / world status。 + Phase 0 guardrails: ```bash @@ -1074,6 +1386,29 @@ Author draft detail / simulate 现在还会直接暴露: - `quality_pass_summary` - `chapter_trace` - `next_actions` +- `creative_cockpit` + - `relationship_network` + - `relationship_hotspots` + - `steering_timeline` + - `chapter_heatmap` + - `issue_priority_groups` + - 每组会带 `primary_validation_panel / primary_validation_panel_label` + - 每个资产优先级也会带 `validation_panel / validation_panel_label / validation_reason` + - `story_structure_snapshot` +- `latest_repair_loop_outcome` + - 用最近一次带 `repair_loop_context` 的 revision 和最新 simulate 做 before/after 对比 +- `repair_loop_history` + - 返回最近几次修稿回路尝试及其 outcome,供 Author shell 展示闭环历史 + - 热点项会携带 `character_id / scene_id / chapter_task_id / arc_id / volume_id` 之类的编辑锚点,供 Author shell 直接 deep-link 到角色卡、scene blueprint 和 chapter task editor +- `POST /v1/author/drafts/{world_version_id}/simulate` 现在可选接收 body: + - `account_id` + - `interactive_scenarios[]` + - `scenario_id? / scenario_kind / label / trigger_chapter?` + - `steering_directive.current_user_intent / summary` + - `steering_directive.impacted_character_ids[]` + - `steering_directive.memory_patch_note?` + - `steering_directive.affected_arc_id?` + - 不带 body 仍保持兼容;如果 scenario 未给 `trigger_chapter`,服务端会默认落到上次 simulate 完成章节之后的下一章,并在需要时自动扩一章预算以保证 steering 生效。 - `validation_drilldown` - `blockers / warning_groups / next_actions` - `revision_compare` @@ -1107,6 +1442,17 @@ Author 主路径现在建议按这个顺序演示: 7. workflow 进入 `准备送审` 8. `送审` +本轮 Author UI 现在还有这些结构变化: + +- `Brief` 改成三组起稿表单:`世界与关系 / 命题与冲突 / 氛围与地点` +- `Draft` 改成局部子工作区:`Assets / Longform / Repair / Style` +- `Simulate` 首屏先给 `latest decision / freshness / issue queue / weakest chapter` +- `Review & Submit` 首屏先给 `送审 readiness / compare evidence / revision stack` +- `Settings` 先给 `登录态 / 通知态 / 协作态` 摘要,再往下展开原始配置 +- `/app` 现改成 `auth-first` 入口:未登录时只显示注册 / 登录页;新注册账号默认是普通用户,登录后进入 `阅读 + 创作` 路径;`reviewer / ops / admin` 权限由管理员后台分配,拥有权限的账号登录后会自动展开 `可审阅内容 / reviewer inbox / 快速审批` +- `/v1/author` 的协作 / 审阅接口现也补了后端权限矩阵:`reviewer inbox`、审批决定、通知偏好和 watcher/notification 协作动作都要求真实 bearer 会话;`reviewer / ops / admin` 才能读取 reviewer inbox 和执行审批决定 +- Author 常见缺字段和失败反馈现在走 shell banner/toast,不再依赖阻断式弹窗 + 面板变化说明: - 创建后:应自动聚焦到 `Draft Detail` @@ -1138,6 +1484,7 @@ Author 主路径现在建议按这个顺序演示: - `python -m src.narrativeos.demo`:可稳定运行 - `demo.py` 连跑 3 次输出稳定:默认返回 Reader Mode 章节摘要与正文预览 - `python -m src.narrativeos.benchmark.runner --baseline-file tests/benchmark_baseline.json --markdown-out artifacts/cross_pack_benchmark_summary.md`:可稳定输出 JSON + markdown summary,包含 `strongest_packs / weakest_packs / top_failing_packs / delta_summary.ranking_changes`;`tests/cross_pack_benchmark_summary.md` 保存当前受版本控制的 markdown baseline +- benchmark summary 现在会原生输出 `phase_a_quality_gate`,markdown 也会直接显示 `Phase A Quality Gate`,供 PR 和 Ops 直接查看共享质量门槛的 pass/fail 与阈值证据 - `python -m src.narrativeos.benchmark.runner --baseline-file tests/long_route_benchmark_baseline.json --max-chapters 36 --min-end-turn-override 30 --markdown-out artifacts/long_route_benchmark_summary.md`:可稳定输出 long-route JSON + markdown summary,包含 `long_route_summary / completion_ratio / stop_reason / mid_arc_pass_rate / late_arc_pass_rate` - `scripts/run_cross_pack_merge_gate.sh`:可本地执行 cross-pack merge gate;GitHub Actions 的 `cross-pack-quality` workflow 也会调用同一套 gate 逻辑 - `cross-pack-quality` workflow 现已在 benchmark step 显式使用 `sqlite:///narrativeos_beta.db`,避免 CI 中 `DATABASE_URL` 缺失时 benchmark runner 直接失败 @@ -1162,7 +1509,7 @@ Author 主路径现在建议按这个顺序演示: `/app` 可切换 `Reader / Author / Ops` Reader 可切换 `Duty / Romance` worlds、创建/恢复/删除 session、预览 route、执行 step、查看 replay Reader 具备 `Story Feed + Sticky Composer + suggested_prefill` - Author 可把当前世界存成 draft、触发 simulate、submit for review,并查看 `revision compare / before-after chapter compare / issue heatmap / weakest chapters / chapter breakdown / style-pacing-hook controls / collaboration / approval` + Author 可把当前世界存成 draft、触发 simulate、submit for review,并查看 `overview hero / brief composer / draft local subsections / revision compare / before-after chapter compare / issue heatmap / weakest chapters / chapter breakdown / style-pacing-hook controls / collaboration / approval` Ops 可查看 review queue、publish、rollback、查看 metering Ops 可查看 `cross-pack quality`、`top failing packs`、`metric delta` diff --git a/db/migrations/0013_author_work_branches.sql b/db/migrations/0013_author_work_branches.sql new file mode 100644 index 0000000..4ffc46f --- /dev/null +++ b/db/migrations/0013_author_work_branches.sql @@ -0,0 +1,34 @@ +alter table if exists author_works add column if not exists branch_id text; +alter table if exists author_works add column if not exists root_work_id text; +alter table if exists author_works add column if not exists parent_work_id text; +alter table if exists author_works add column if not exists branch_name text; +alter table if exists author_works add column if not exists branch_kind text; +alter table if exists author_works add column if not exists branch_origin_label text; +alter table if exists author_works add column if not exists fork_after_chapter_index integer default 0; +alter table if exists author_works add column if not exists is_active_line integer default 0; + +update author_works +set root_work_id = work_id +where root_work_id is null; + +update author_works +set branch_id = work_id +where branch_id is null; + +update author_works +set branch_name = '主线' +where branch_name is null; + +update author_works +set branch_kind = 'mainline' +where branch_kind is null; + +update author_works +set fork_after_chapter_index = 0 +where fork_after_chapter_index is null; + +update author_works +set is_active_line = 1 +where is_active_line is null; + +create index if not exists idx_author_works_root_work_updated_at on author_works(root_work_id, updated_at); diff --git a/db/postgres_schema.sql b/db/postgres_schema.sql index 72995f4..6e32bf5 100644 --- a/db/postgres_schema.sql +++ b/db/postgres_schema.sql @@ -341,3 +341,1454 @@ 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); + +alter table if exists author_works add column if not exists branch_id text; +alter table if exists author_works add column if not exists root_work_id text; +alter table if exists author_works add column if not exists parent_work_id text; +alter table if exists author_works add column if not exists branch_name text; +alter table if exists author_works add column if not exists branch_kind text; +alter table if exists author_works add column if not exists branch_origin_label text; +alter table if exists author_works add column if not exists fork_after_chapter_index integer default 0; +alter table if exists author_works add column if not exists is_active_line integer default 0; + +update author_works +set root_work_id = work_id +where root_work_id is null; + +update author_works +set branch_id = work_id +where branch_id is null; + +update author_works +set branch_name = '主线' +where branch_name is null; + +update author_works +set branch_kind = 'mainline' +where branch_kind is null; + +update author_works +set fork_after_chapter_index = 0 +where fork_after_chapter_index is null; + +update author_works +set is_active_line = 1 +where is_active_line is null; + +create index if not exists idx_author_works_root_work_updated_at on author_works(root_work_id, updated_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); + +create table if not exists plans ( + plan_id text primary key, + display_name text not null, + subscription_tier text not null, + monthly_price_usd numeric not null default 0, + status text not null default 'active', + seat_limit integer not null default 0, + workspace_limit integer not null default 0, + campaign_limit integer not null default 0, + plan_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_plans_status_updated_at on plans(status, updated_at); + +create table if not exists customer_accounts ( + customer_account_id text primary key, + account_id text not null unique, + display_name text, + status text not null default 'trial', + plan_id text not null, + seat_limit integer not null default 0, + workspace_limit integer not null default 0, + campaign_limit integer not null default 0, + seat_count integer not null default 0, + workspace_count integer not null default 0, + campaign_count integer not null default 0, + renewal_due_at timestamptz, + metadata_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_customer_accounts_status_updated_at on customer_accounts(status, updated_at); +create index if not exists idx_customer_accounts_plan_status_updated_at on customer_accounts(plan_id, status, updated_at); +create index if not exists idx_customer_accounts_renewal_due_at on customer_accounts(renewal_due_at); + +create table if not exists billing_profiles ( + billing_profile_id text primary key, + customer_account_id text not null, + account_id text not null, + provider text not null, + provider_customer_ref text, + invoice_email text, + legal_name text, + billing_country text, + tax_status text, + status text not null default 'active', + profile_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_billing_profiles_customer_updated_at on billing_profiles(customer_account_id, updated_at); +create index if not exists idx_billing_profiles_account_updated_at on billing_profiles(account_id, updated_at); +create index if not exists idx_billing_profiles_provider_status_updated_at on billing_profiles(provider, status, updated_at); + +create table if not exists usage_ledgers ( + usage_ledger_id text primary key, + account_id text not null, + customer_account_id text, + plan_id text, + status text not null default 'open', + billing_period_start timestamptz not null, + billing_period_end timestamptz not null, + presented_count integer not null default 0, + handoff_count integer not null default 0, + conversion_count integer not null default 0, + subtotal_amount_usd numeric not null default 0, + disputed_amount_usd numeric not null default 0, + credited_amount_usd numeric not null default 0, + reversed_amount_usd numeric not null default 0, + ledger_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_usage_ledgers_account_period_updated_at on usage_ledgers(account_id, billing_period_start, updated_at); +create index if not exists idx_usage_ledgers_customer_period_updated_at on usage_ledgers(customer_account_id, billing_period_start, updated_at); +create index if not exists idx_usage_ledgers_status_updated_at on usage_ledgers(status, updated_at); + +create table if not exists billable_events ( + billable_event_id text primary key, + usage_ledger_id text, + account_id text not null, + customer_account_id text, + plan_id text, + billable_metric text not null, + status text not null default 'recorded', + trace_id text, + quality_event_id text, + runtime_receipt_event_id text, + feedback_item_id text, + source_surface text, + world_version_id text, + session_id text, + quantity numeric not null default 1, + unit_price_usd numeric not null default 0, + amount_usd numeric not null default 0, + reason_codes_json jsonb not null default '[]', + event_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_billable_events_account_created_at on billable_events(account_id, created_at); +create index if not exists idx_billable_events_customer_created_at on billable_events(customer_account_id, created_at); +create index if not exists idx_billable_events_trace_created_at on billable_events(trace_id, created_at); +create index if not exists idx_billable_events_metric_status_created_at on billable_events(billable_metric, status, created_at); + +create table if not exists invoice_previews ( + invoice_preview_id text primary key, + usage_ledger_id text, + account_id text not null, + customer_account_id text, + plan_id text, + status text not null default 'draft', + billing_period_start timestamptz not null, + billing_period_end timestamptz not null, + subtotal_amount_usd numeric not null default 0, + credits_applied_usd numeric not null default 0, + disputed_amount_usd numeric not null default 0, + credited_amount_usd numeric not null default 0, + reversed_amount_usd numeric not null default 0, + total_due_usd numeric not null default 0, + line_items_json jsonb not null default '[]', + summary_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_invoice_previews_account_period_updated_at on invoice_previews(account_id, billing_period_start, updated_at); +create index if not exists idx_invoice_previews_customer_period_updated_at on invoice_previews(customer_account_id, billing_period_start, updated_at); + +create table if not exists credit_balances ( + credit_balance_id text primary key, + account_id text not null, + customer_account_id text, + balance_type text not null, + amount_usd numeric not null default 0, + source_ref_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_credit_balances_account_updated_at on credit_balances(account_id, updated_at); +create index if not exists idx_credit_balances_customer_updated_at on credit_balances(customer_account_id, updated_at); +create index if not exists idx_credit_balances_type_updated_at on credit_balances(balance_type, updated_at); + +create table if not exists overage_flags ( + overage_flag_id text primary key, + account_id text not null, + customer_account_id text, + plan_id text, + metric_type text not null, + status text not null default 'active', + observed_units numeric not null default 0, + included_units numeric not null default 0, + overage_units numeric not null default 0, + flag_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_overage_flags_account_status_updated_at on overage_flags(account_id, status, updated_at); +create index if not exists idx_overage_flags_metric_status_updated_at on overage_flags(metric_type, status, updated_at); + +create table if not exists campaigns ( + campaign_id text primary key, + customer_account_id text not null, + account_id text not null, + title text not null, + target_icp_vertical text not null, + cta_text text not null, + disclosure_text text not null, + activation_status text not null default 'draft', + selected_channels_json jsonb not null default '[]', + selected_partner_refs_json jsonb not null default '[]', + primary_review_case_id text, + latest_submission_id text, + campaign_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_campaigns_account_status_updated_at on campaigns(account_id, activation_status, updated_at); +create index if not exists idx_campaigns_customer_status_updated_at on campaigns(customer_account_id, activation_status, updated_at); + +create table if not exists campaign_proof_bundles ( + proof_bundle_id text primary key, + campaign_id text not null, + bundle_label text not null default 'default', + proof_points_json jsonb not null default '[]', + source_urls_json jsonb not null default '[]', + artifact_refs_json jsonb not null default '[]', + bundle_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_campaign_proof_bundles_campaign_updated_at on campaign_proof_bundles(campaign_id, updated_at); + +create table if not exists campaign_channel_targets ( + channel_target_id text primary key, + campaign_id text not null, + channel_name text not null, + partner_ref text, + priority integer not null default 0, + readiness_status text not null default 'selected', + target_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_campaign_channel_targets_campaign_priority_updated_at on campaign_channel_targets(campaign_id, priority, updated_at); + +create table if not exists campaign_review_submissions ( + submission_id text primary key, + campaign_id text not null, + review_case_id text, + status text not null default 'submitted', + submitted_by text not null, + reviewer_id text, + decision_note text, + submitted_at timestamptz not null default CURRENT_TIMESTAMP, + decided_at timestamptz, + submission_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_campaign_review_submissions_campaign_updated_at on campaign_review_submissions(campaign_id, updated_at); +create index if not exists idx_campaign_review_submissions_review_case_updated_at on campaign_review_submissions(review_case_id, updated_at); +create index if not exists idx_campaign_review_submissions_status_updated_at on campaign_review_submissions(status, updated_at); + +create table if not exists partners ( + partner_id text primary key, + name text not null, + lifecycle_status text not null default 'discovered', + sla_status text not null default 'unknown', + receipt_capability text not null default 'unknown', + disclosure_readiness text not null default 'unknown', + billing_readiness text not null default 'unknown', + allowlisted_channels_json jsonb not null default '[]', + primary_endpoint_url text, + endpoint_health_status text not null default 'unknown', + partner_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_partners_lifecycle_updated_at on partners(lifecycle_status, updated_at); +create index if not exists idx_partners_endpoint_health_updated_at on partners(endpoint_health_status, updated_at); + +create table if not exists partner_capabilities ( + partner_capability_id text primary key, + partner_id text not null, + capability_type text not null, + status text not null default 'unknown', + capability_value text, + capability_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_partner_capabilities_partner_updated_at on partner_capabilities(partner_id, updated_at); +create index if not exists idx_partner_capabilities_type_status_updated_at on partner_capabilities(capability_type, status, updated_at); + +create table if not exists partner_health_checks ( + health_check_id text primary key, + partner_id text not null, + endpoint_url text, + status text not null default 'unknown', + status_code integer, + response_time_ms numeric, + checked_at timestamptz not null default CURRENT_TIMESTAMP, + health_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_partner_health_checks_partner_checked_at on partner_health_checks(partner_id, checked_at); +create index if not exists idx_partner_health_checks_status_checked_at on partner_health_checks(status, checked_at); + +create table if not exists disputes ( + dispute_id text primary key, + customer_account_id text not null, + account_id text not null, + campaign_id text, + invoice_preview_id text, + billable_event_id text, + quality_event_id text, + trace_id text, + dispute_reason_code text not null, + note text, + status text not null default 'open', + requested_amount_usd numeric not null default 0, + resolved_amount_usd numeric not null default 0, + requested_by text not null, + reviewer_id text, + resolution_note text, + dispute_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_disputes_account_status_updated_at on disputes(account_id, status, updated_at); +create index if not exists idx_disputes_customer_status_updated_at on disputes(customer_account_id, status, updated_at); +create index if not exists idx_disputes_billable_event_updated_at on disputes(billable_event_id, updated_at); + +create table if not exists refund_requests ( + refund_request_id text primary key, + dispute_id text, + customer_account_id text not null, + account_id text not null, + invoice_preview_id text, + billable_event_id text, + trace_id text, + status text not null default 'requested', + requested_amount_usd numeric not null default 0, + approved_amount_usd numeric not null default 0, + requested_by text not null, + reviewer_id text, + refund_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_refund_requests_account_status_updated_at on refund_requests(account_id, status, updated_at); +create index if not exists idx_refund_requests_dispute_updated_at on refund_requests(dispute_id, updated_at); + +create table if not exists settlement_runs ( + settlement_run_id text primary key, + customer_account_id text, + account_id text, + billing_period_start timestamptz, + billing_period_end timestamptz, + status text not null default 'draft', + subtotal_amount_usd numeric not null default 0, + disputed_amount_usd numeric not null default 0, + credited_amount_usd numeric not null default 0, + reversed_amount_usd numeric not null default 0, + refunded_amount_usd numeric not null default 0, + net_amount_usd numeric not null default 0, + run_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_settlement_runs_account_updated_at on settlement_runs(account_id, updated_at); +create index if not exists idx_settlement_runs_status_updated_at on settlement_runs(status, updated_at); + +create table if not exists settlement_items ( + settlement_item_id text primary key, + settlement_run_id text not null, + billable_event_id text, + invoice_preview_id text, + dispute_id text, + refund_request_id text, + status text not null default 'approved', + amount_usd numeric not null default 0, + item_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_settlement_items_run_status_created_at on settlement_items(settlement_run_id, status, created_at); + +create table if not exists support_cases ( + support_case_id text primary key, + customer_account_id text not null, + account_id text not null, + campaign_id text, + invoice_preview_id text, + billable_event_id text, + quality_event_id text, + trace_id text, + case_type text not null default 'general', + subject text not null, + description text not null, + status text not null default 'open', + priority text not null default 'medium', + requested_by text not null, + owner_id text, + resolution_note text, + support_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_support_cases_account_status_updated_at on support_cases(account_id, status, updated_at); +create index if not exists idx_support_cases_owner_status_updated_at on support_cases(owner_id, status, updated_at); + +create table if not exists manual_adjustments ( + adjustment_id text primary key, + customer_account_id text not null, + account_id text not null, + dispute_id text, + refund_request_id text, + invoice_preview_id text, + billable_event_id text, + adjustment_type text not null, + amount_usd numeric not null default 0, + status text not null default 'applied', + requested_by text not null, + reviewer_id text, + adjustment_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_manual_adjustments_account_status_updated_at on manual_adjustments(account_id, status, updated_at); +create index if not exists idx_manual_adjustments_dispute_updated_at on manual_adjustments(dispute_id, updated_at); + +create table if not exists audit_logs ( + audit_log_id text primary key, + actor_id text not null, + actor_role text not null, + account_id text, + customer_account_id text, + object_type text not null, + object_id text not null, + action_type text not null, + source_surface text not null, + customer_visible_payload_json jsonb not null default '{}', + internal_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_audit_logs_account_created_at on audit_logs(account_id, created_at); +create index if not exists idx_audit_logs_customer_created_at on audit_logs(customer_account_id, created_at); +create index if not exists idx_audit_logs_actor_created_at on audit_logs(actor_id, created_at); +create index if not exists idx_audit_logs_action_created_at on audit_logs(action_type, created_at); + +create table if not exists customer_audit_exports ( + audit_export_id text primary key, + customer_account_id text not null, + account_id text not null, + requested_by text not null, + period_start timestamptz, + period_end timestamptz, + export_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_customer_audit_exports_account_created_at on customer_audit_exports(account_id, created_at); +create index if not exists idx_customer_audit_exports_customer_created_at on customer_audit_exports(customer_account_id, created_at); + +create table if not exists data_retention_policies ( + retention_policy_id text primary key, + scope text not null, + retention_days integer not null default 30, + deletion_mode text not null default 'manual_request', + status text not null default 'active', + policy_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_data_retention_policies_scope_status_updated_at on data_retention_policies(scope, status, updated_at); + +create table if not exists data_deletion_requests ( + deletion_request_id text primary key, + customer_account_id text not null, + account_id text not null, + requested_by text not null, + scope text not null, + status text not null default 'requested', + requested_payload_json jsonb not null default '{}', + affected_object_counts_json jsonb not null default '{}', + resolution_note text, + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_data_deletion_requests_account_status_updated_at on data_deletion_requests(account_id, status, updated_at); +create index if not exists idx_data_deletion_requests_customer_status_updated_at on data_deletion_requests(customer_account_id, status, updated_at); + +create table if not exists invoice_issuances ( + invoice_id text primary key, + invoice_preview_id text not null, + customer_account_id text not null, + account_id text not null, + provider text not null, + provider_invoice_ref text, + provider_customer_ref text, + status text not null default 'draft', + currency text not null default 'USD', + subtotal_amount_usd numeric not null default 0, + total_due_usd numeric not null default 0, + hosted_invoice_url text, + invoice_pdf_url text, + issued_at timestamptz, + paid_at timestamptz, + voided_at timestamptz, + invoice_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_invoice_issuances_account_status_updated_at on invoice_issuances(account_id, status, updated_at); +create index if not exists idx_invoice_issuances_customer_status_updated_at on invoice_issuances(customer_account_id, status, updated_at); +create index if not exists idx_invoice_issuances_provider_ref_updated_at on invoice_issuances(provider_invoice_ref, updated_at); + +create table if not exists payment_transactions ( + payment_transaction_id text primary key, + invoice_id text, + customer_account_id text, + account_id text not null, + provider text not null, + provider_transaction_ref text, + transaction_type text not null default 'payment', + status text not null default 'pending', + amount_usd numeric not null default 0, + currency text not null default 'USD', + trace_id text, + transaction_payload_json jsonb not null default '{}', + occurred_at timestamptz not null default CURRENT_TIMESTAMP, + created_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_payment_transactions_account_occurred_at on payment_transactions(account_id, occurred_at); +create index if not exists idx_payment_transactions_invoice_occurred_at on payment_transactions(invoice_id, occurred_at); +create index if not exists idx_payment_transactions_provider_ref_occurred_at on payment_transactions(provider_transaction_ref, occurred_at); + +create table if not exists provider_webhook_events ( + provider_webhook_event_id text primary key, + provider text not null, + provider_event_id text not null, + event_type text not null, + status text not null default 'received', + invoice_id text, + account_id text, + payload_json jsonb not null default '{}', + processing_result_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + processed_at timestamptz +); + +create index if not exists idx_provider_webhook_events_provider_created_at on provider_webhook_events(provider, created_at); +create index if not exists idx_provider_webhook_events_provider_event_created_at on provider_webhook_events(provider_event_id, created_at); +create index if not exists idx_provider_webhook_events_status_created_at on provider_webhook_events(status, created_at); + +create table if not exists credit_notes ( + credit_note_id text primary key, + invoice_id text not null, + customer_account_id text, + account_id text not null, + provider text not null, + provider_credit_note_ref text, + status text not null default 'issued', + amount_usd numeric not null default 0, + reason text, + credit_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_credit_notes_invoice_created_at on credit_notes(invoice_id, created_at); +create index if not exists idx_credit_notes_provider_ref_created_at on credit_notes(provider_credit_note_ref, created_at); + +create table if not exists payment_retry_attempts ( + payment_retry_attempt_id text primary key, + invoice_id text, + customer_account_id text, + account_id text not null, + provider text not null, + status text not null default 'planned', + retry_reason text, + attempt_count integer not null default 1, + next_retry_at timestamptz, + retry_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_payment_retry_attempts_invoice_updated_at on payment_retry_attempts(invoice_id, updated_at); +create index if not exists idx_payment_retry_attempts_account_updated_at on payment_retry_attempts(account_id, updated_at); + +create table if not exists dunning_events ( + dunning_event_id text primary key, + invoice_id text, + customer_account_id text, + account_id text not null, + status text not null default 'scheduled', + step text not null, + event_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_dunning_events_invoice_created_at on dunning_events(invoice_id, created_at); +create index if not exists idx_dunning_events_account_created_at on dunning_events(account_id, created_at); + +create table if not exists renewal_trackers ( + renewal_tracker_id text primary key, + customer_account_id text not null, + account_id text not null, + status text not null default 'stable', + renewal_due_at timestamptz, + tracker_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_renewal_trackers_account_status_updated_at on renewal_trackers(account_id, status, updated_at); + +create table if not exists dunning_runs ( + dunning_run_id text primary key, + customer_account_id text not null, + account_id text not null, + invoice_id text, + status text not null default 'open', + current_step text not null default 'initial_notice', + dunning_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_dunning_runs_account_status_updated_at on dunning_runs(account_id, status, updated_at); + +create table if not exists pilot_conversion_tracks ( + pilot_conversion_track_id text primary key, + customer_account_id text not null, + account_id text not null, + status text not null default 'watch', + track_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_pilot_conversion_tracks_account_status_updated_at on pilot_conversion_tracks(account_id, status, updated_at); + +create table if not exists expansion_candidates ( + expansion_candidate_id text primary key, + customer_account_id text not null, + account_id text not null, + status text not null default 'watch', + trigger_type text not null, + candidate_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_expansion_candidates_account_status_updated_at on expansion_candidates(account_id, status, updated_at); + +create table if not exists churn_risk_flags ( + churn_risk_flag_id text primary key, + customer_account_id text not null, + account_id text not null, + status text not null default 'watch', + risk_level text not null default 'medium', + flag_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_churn_risk_flags_account_status_updated_at on churn_risk_flags(account_id, status, updated_at); + +create table if not exists production_signoffs ( + signoff_id text primary key, + launch_label text not null, + status text not null default 'draft', + source_go_live_checklist_id text, + source_manual_signoff_bundle_id text, + rollup_summary_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_production_signoffs_status_updated_at on production_signoffs(status, updated_at); +create index if not exists idx_production_signoffs_launch_label_updated_at on production_signoffs(launch_label, updated_at); + +create table if not exists production_signoff_items ( + signoff_item_id text primary key, + signoff_id text not null, + item_code text not null, + category text not null, + label text not null, + owner_role text not null, + owner_actor_id text, + due_at timestamptz, + status text not null default 'pending', + decision_note text, + approved_at timestamptz, + evidence_count integer not null default 0, + item_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_production_signoff_items_signoff_status_due_at on production_signoff_items(signoff_id, status, due_at); +create index if not exists idx_production_signoff_items_owner_status_due_at on production_signoff_items(owner_role, status, due_at); +create index if not exists idx_production_signoff_items_code_status_updated_at on production_signoff_items(item_code, status, updated_at); + +create table if not exists production_signoff_evidence ( + evidence_id text primary key, + signoff_id text not null, + signoff_item_id text not null, + evidence_type text not null, + source_ref_json jsonb not null default '{}', + summary text, + customer_safe boolean not null default false, + payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_production_signoff_evidence_item_created_at on production_signoff_evidence(signoff_item_id, created_at); +create index if not exists idx_production_signoff_evidence_signoff_created_at on production_signoff_evidence(signoff_id, created_at); + +create table if not exists production_cutover_windows ( + cutover_window_id text primary key, + signoff_id text not null, + launch_wave text not null, + target_environment text not null, + starts_at timestamptz, + ends_at timestamptz, + rollback_owner_role text, + status text not null default 'planned', + cutover_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_production_cutover_windows_signoff_status_starts_at on production_cutover_windows(signoff_id, status, starts_at); +create index if not exists idx_production_cutover_windows_env_status_starts_at on production_cutover_windows(target_environment, status, starts_at); + +create table if not exists production_customer_acceptance_records ( + acceptance_record_id text primary key, + customer_account_id text not null, + account_id text not null, + signoff_id text, + launch_wave text not null, + status text not null default 'draft', + readiness_summary_json jsonb not null default '{}', + acceptance_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_production_customer_acceptance_account_status_updated_at on production_customer_acceptance_records(account_id, status, updated_at); +create index if not exists idx_production_customer_acceptance_wave_status_updated_at on production_customer_acceptance_records(launch_wave, status, updated_at); + +create table if not exists go_live_ready_accounts ( + go_live_ready_account_id text primary key, + customer_account_id text not null, + account_id text not null, + acceptance_record_id text not null, + launch_wave text not null, + status text not null default 'candidate', + readiness_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_go_live_ready_accounts_wave_status_updated_at on go_live_ready_accounts(launch_wave, status, updated_at); +create index if not exists idx_go_live_ready_accounts_account_status_updated_at on go_live_ready_accounts(account_id, status, updated_at); + +create table if not exists launch_wave_statuses ( + launch_wave_status_id text primary key, + launch_wave text not null, + status text not null default 'planned', + target_environment text not null default 'production', + wave_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_launch_wave_statuses_wave_status_updated_at on launch_wave_statuses(launch_wave, status, updated_at); + +create table if not exists production_preflight_runs ( + preflight_run_id text primary key, + signoff_id text, + launch_wave text not null, + target_environment text not null default 'production', + status text not null default 'running', + go_no_go text not null default 'manual_review', + hard_fail_count integer not null default 0, + soft_fail_count integer not null default 0, + run_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_production_preflight_runs_signoff_status_updated_at on production_preflight_runs(signoff_id, status, updated_at); +create index if not exists idx_production_preflight_runs_wave_status_updated_at on production_preflight_runs(launch_wave, status, updated_at); + +create table if not exists production_preflight_checks ( + preflight_check_id text primary key, + preflight_run_id text not null, + check_key text not null, + linked_signoff_item_code text, + owner_role text not null, + status text not null default 'passed', + summary text, + evidence_ref text, + payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_production_preflight_checks_run_status_created_at on production_preflight_checks(preflight_run_id, status, created_at); +create index if not exists idx_production_preflight_checks_linked_item_status_created_at on production_preflight_checks(linked_signoff_item_code, status, created_at); + +create table if not exists first_7_day_outcomes ( + first_7_day_outcome_id text primary key, + account_id text not null, + customer_account_id text, + launch_wave text not null, + launch_anchor_at timestamptz, + outcome_payload_json jsonb not null default '{}', + generated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_first_7_day_outcomes_account_generated_at on first_7_day_outcomes(account_id, generated_at); +create index if not exists idx_first_7_day_outcomes_wave_generated_at on first_7_day_outcomes(launch_wave, generated_at); + +create table if not exists first_30_day_value_summaries ( + first_30_day_value_summary_id text primary key, + account_id text not null, + customer_account_id text, + launch_wave text not null, + launch_anchor_at timestamptz, + provisional boolean not null default true, + summary_payload_json jsonb not null default '{}', + generated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_first_30_day_value_summaries_account_generated_at on first_30_day_value_summaries(account_id, generated_at); +create index if not exists idx_first_30_day_value_summaries_wave_generated_at on first_30_day_value_summaries(launch_wave, generated_at); + +create table if not exists pilot_to_paid_readiness_scores ( + pilot_to_paid_readiness_score_id text primary key, + account_id text not null, + customer_account_id text, + launch_wave text not null, + launch_anchor_at timestamptz, + score double precision not null default 0, + band text not null default 'watch', + score_payload_json jsonb not null default '{}', + generated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_pilot_to_paid_readiness_scores_account_generated_at on pilot_to_paid_readiness_scores(account_id, generated_at); +create index if not exists idx_pilot_to_paid_readiness_scores_wave_generated_at on pilot_to_paid_readiness_scores(launch_wave, generated_at); + +create table if not exists customer_success_snapshots ( + customer_success_snapshot_id text primary key, + account_id text not null, + customer_account_id text, + launch_wave text not null, + launch_anchor_at timestamptz, + snapshot_payload_json jsonb not null default '{}', + generated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_customer_success_snapshots_account_generated_at on customer_success_snapshots(account_id, generated_at); +create index if not exists idx_customer_success_snapshots_wave_generated_at on customer_success_snapshots(launch_wave, generated_at); + +create table if not exists production_launch_events ( + launch_event_id text primary key, + launch_wave text not null, + account_id text, + event_category text not null, + event_type text not null, + phase text not null, + severity text not null default 'info', + related_object_type text, + related_object_id text, + occurred_at timestamptz not null default CURRENT_TIMESTAMP, + event_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_production_launch_events_wave_phase_occurred_at on production_launch_events(launch_wave, phase, occurred_at); +create index if not exists idx_production_launch_events_account_severity_occurred_at on production_launch_events(account_id, severity, occurred_at); + +create table if not exists production_postmortem_records ( + postmortem_record_id text primary key, + launch_wave text not null, + account_id text, + status text not null default 'draft', + summary_json jsonb not null default '{}', + generated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_production_postmortem_records_wave_status_generated_at on production_postmortem_records(launch_wave, status, generated_at); +create index if not exists idx_production_postmortem_records_account_status_generated_at on production_postmortem_records(account_id, status, generated_at); + +create table if not exists go_live_day_runs ( + go_live_day_run_id text primary key, + signoff_id text, + launch_wave text not null, + account_id text, + status text not null default 'running', + activation_state_before text, + activation_state_after text, + report_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_go_live_day_runs_wave_status_updated_at on go_live_day_runs(launch_wave, status, updated_at); + +create table if not exists go_live_day_checkpoints ( + go_live_day_checkpoint_id text primary key, + go_live_day_run_id text not null, + checkpoint_key text not null, + status text not null default 'passed', + summary text, + evidence_ref text, + rollback_recommendation text, + checkpoint_payload_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_go_live_day_checkpoints_run_created_at on go_live_day_checkpoints(go_live_day_run_id, created_at); +create index if not exists idx_go_live_day_checkpoints_key_status_created_at on go_live_day_checkpoints(checkpoint_key, status, created_at); + +create table if not exists launch_week_guard_runs ( + launch_week_guard_run_id text primary key, + launch_wave text not null, + account_id text, + status text not null default 'not_ready', + replication_readiness text not null default 'not_ready', + summary_json jsonb not null default '{}', + generated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_launch_week_guard_runs_wave_status_generated_at on launch_week_guard_runs(launch_wave, status, generated_at); + +create table if not exists first_customer_success_packs ( + first_customer_success_pack_id text primary key, + launch_wave text not null, + account_id text, + status text not null default 'not_ready', + pack_payload_json jsonb not null default '{}', + generated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_first_customer_success_packs_wave_status_generated_at on first_customer_success_packs(launch_wave, status, generated_at); + +create table if not exists auth_identity_profiles ( + actor_id text primary key references auth_identities(actor_id), + account_id text, + email_address text, + email_verified text not null default 'false', + verification_required text not null default 'false', + verification_sent_at timestamptz, + verified_at timestamptz, + password_reset_sent_at timestamptz, + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_auth_identity_profiles_account_id on auth_identity_profiles(account_id); +create index if not exists idx_auth_identity_profiles_email_address on auth_identity_profiles(email_address); + +create table if not exists auth_flow_tokens ( + flow_token_id text primary key, + actor_id text not null references auth_identities(actor_id), + account_id text, + flow_type text not null, + token_hash text not null, + status text not null default 'active', + payload_json jsonb, + expires_at timestamptz, + consumed_at timestamptz, + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_auth_flow_tokens_actor_id on auth_flow_tokens(actor_id); +create index if not exists idx_auth_flow_tokens_account_id on auth_flow_tokens(account_id); +create index if not exists idx_auth_flow_tokens_flow_type on auth_flow_tokens(flow_type); +create index if not exists idx_auth_flow_tokens_token_hash on auth_flow_tokens(token_hash); + +create table if not exists auth_delivery_attempts ( + attempt_id text primary key, + actor_id text, + account_id text, + flow_type text not null, + provider text not null, + email_mode text not null, + sender_email text, + recipient_email text not null, + status text not null, + provider_message_id text, + error_code text, + error_reason text, + retryable text not null default 'false', + metadata_json jsonb, + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_auth_delivery_attempts_actor_flow_created_at on auth_delivery_attempts(actor_id, flow_type, created_at); +create index if not exists idx_auth_delivery_attempts_recipient_created_at on auth_delivery_attempts(recipient_email, created_at); +create index if not exists idx_auth_delivery_attempts_status_created_at on auth_delivery_attempts(status, created_at); +create index if not exists idx_auth_delivery_attempts_provider_message_id on auth_delivery_attempts(provider_message_id); +create index if not exists idx_auth_delivery_attempts_error_code on auth_delivery_attempts(error_code); + +create table if not exists showcase_work_likes ( + showcase_like_id text primary key, + world_id text not null, + world_version_id text not null, + account_id text not null, + actor_id text, + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create unique index if not exists uq_showcase_work_likes_world_account on showcase_work_likes(world_id, account_id); +create index if not exists idx_showcase_work_likes_world_created_at on showcase_work_likes(world_id, created_at); +create index if not exists idx_showcase_work_likes_account_created_at on showcase_work_likes(account_id, created_at); + +create table if not exists showcase_work_comments ( + showcase_comment_id text primary key, + world_id text not null, + world_version_id text not null, + account_id text not null, + actor_id text, + author_name text not null, + content text not null, + status text not null default 'published', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_showcase_work_comments_world_status_created_at on showcase_work_comments(world_id, status, created_at); +create index if not exists idx_showcase_work_comments_account_created_at on showcase_work_comments(account_id, created_at); + +create table if not exists showcase_work_tips ( + showcase_tip_id text primary key, + world_id text not null, + world_version_id text not null, + account_id text not null, + actor_id text, + amount integer not null default 0, + wallet_type text not null default 'story_credits', + balance_after double precision not null default 0, + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_showcase_work_tips_world_created_at on showcase_work_tips(world_id, created_at); +create index if not exists idx_showcase_work_tips_account_created_at on showcase_work_tips(account_id, created_at); + +create table if not exists story_session_bookmarks ( + bookmark_id text primary key, + session_id text not null, + account_id text not null, + node_id text not null, + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create unique index if not exists uq_story_session_bookmarks_session_account_node on story_session_bookmarks(session_id, account_id, node_id); +create index if not exists idx_story_session_bookmarks_session_created_at on story_session_bookmarks(session_id, created_at); +create index if not exists idx_story_session_bookmarks_account_created_at on story_session_bookmarks(account_id, created_at); + +create table if not exists story_session_share_tokens ( + share_token text primary key, + session_id text not null, + account_id text not null, + node_id text not null, + sharer_name text not null, + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create unique index if not exists uq_story_session_share_tokens_session_account_node on story_session_share_tokens(session_id, account_id, node_id); +create index if not exists idx_story_session_share_tokens_token_created_at on story_session_share_tokens(share_token, created_at); +create index if not exists idx_story_session_share_tokens_session_created_at on story_session_share_tokens(session_id, created_at); +create index if not exists idx_story_session_share_tokens_account_created_at on story_session_share_tokens(account_id, created_at); + +drop index if exists uq_story_session_share_tokens_session_account_node; + +alter table story_session_share_tokens + add column if not exists status text not null default 'active'; + +alter table story_session_share_tokens + add column if not exists expires_at timestamptz; + +alter table story_session_share_tokens + add column if not exists revoked_at timestamptz; + +create index if not exists idx_story_session_share_tokens_session_account_node_status + on story_session_share_tokens(session_id, account_id, node_id, status); + +alter table auth_identity_profiles + add column if not exists avatar_url text; + +alter table auth_identity_profiles + add column if not exists ui_preferences_json jsonb; + +alter table auth_identity_profiles + add column if not exists deactivated_at timestamptz; + +alter table auth_identity_profiles + add column if not exists deactivated_by text; + +alter table auth_identity_profiles + add column if not exists deactivation_reason text; + +alter table auth_identity_profiles + add column if not exists pending_email_address text; + +alter table auth_identity_profiles + add column if not exists pending_email_change_requested_at timestamptz; + +alter table auth_identity_profiles + add column if not exists email_change_last_sent_at timestamptz; + +create index if not exists idx_auth_identity_profiles_pending_email_address + on auth_identity_profiles(pending_email_address); + +alter table billing_checkout_sessions + add column if not exists checkout_kind text not null default 'subscription'; + +alter table billing_checkout_sessions + add column if not exists package_id text; + +alter table billing_checkout_sessions + add column if not exists fulfilled_at timestamptz; + +create table if not exists soul_profile_preferences ( + actor_id text primary key, + account_id text, + genres_json jsonb not null default '[]', + styles_json jsonb not null default '[]', + privacy_mode text not null default 'followers', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_soul_profile_preferences_account_updated_at on soul_profile_preferences(account_id, updated_at); + +create table if not exists library_work_favorites ( + favorite_id text primary key, + account_id text not null, + work_id text not null, + work_kind text not null, + title_snapshot text, + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create unique index if not exists uq_library_work_favorites_account_work on library_work_favorites(account_id, work_id); +create index if not exists idx_library_work_favorites_account_created_at on library_work_favorites(account_id, created_at); +create index if not exists idx_library_work_favorites_work_created_at on library_work_favorites(work_id, created_at); + +create table if not exists library_follows ( + follow_id text primary key, + account_id text not null, + target_type text not null, + target_id text not null, + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create unique index if not exists uq_library_follows_account_target on library_follows(account_id, target_type, target_id); +create index if not exists idx_library_follows_account_created_at on library_follows(account_id, created_at); +create index if not exists idx_library_follows_target_created_at on library_follows(target_type, target_id, created_at); + +create table if not exists showcase_work_views ( + showcase_view_id text primary key, + world_id text not null, + world_version_id text not null, + account_id text, + viewer_key text not null, + event_type text not null default 'view', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create unique index if not exists uq_showcase_work_views_world_viewer_event on showcase_work_views(world_id, viewer_key, event_type); +create index if not exists idx_showcase_work_views_world_event_created_at on showcase_work_views(world_id, event_type, created_at); +create index if not exists idx_showcase_work_views_account_event_created_at on showcase_work_views(account_id, event_type, created_at); + +create table if not exists author_project_graphs ( + project_id text primary key, + world_version_id text not null unique, + account_id text not null, + engine text not null default 'balanced', + enabled_rule_ids_json jsonb not null default '[]', + nodes_json jsonb not null default '[]', + connections_json jsonb not null default '[]', + metadata_json jsonb not null default '{}', + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create index if not exists idx_author_project_graphs_account_updated_at on author_project_graphs(account_id, updated_at); +create index if not exists idx_author_project_graphs_world_version_updated_at on author_project_graphs(world_version_id, updated_at); + +create table if not exists ops_configs ( + ops_config_id text primary key, + config_type text not null, + scope_key text, + status text not null default 'active', + config_payload_json jsonb not null default '{}', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_ops_configs_type_scope_updated_at on ops_configs(config_type, scope_key, updated_at); +create index if not exists idx_ops_configs_status_updated_at on ops_configs(status, updated_at); + +insert into ops_configs ( + ops_config_id, + config_type, + scope_key, + status, + config_payload_json, + created_at, + updated_at +) +select + 'governance_capacity_override::' || risk_tier as ops_config_id, + 'governance_capacity_override' as config_type, + risk_tier as scope_key, + case when mode = 'active' then 'active' else 'disabled' end as status, + policy_payload_json as config_payload_json, + created_at, + updated_at +from quality_policies +where scenario_id = 'governance_capacity_override' +on conflict (ops_config_id) do update +set + status = excluded.status, + config_payload_json = excluded.config_payload_json, + updated_at = excluded.updated_at; + +create table if not exists library_stats_cubes ( + library_stats_cube_id text primary key, + account_id text not null, + snapshot_payload_json jsonb not null default '{}', + source_updated_at timestamptz not null default CURRENT_TIMESTAMP, + created_at timestamptz not null default CURRENT_TIMESTAMP, + updated_at timestamptz not null default CURRENT_TIMESTAMP +); + +create unique index if not exists uq_library_stats_cubes_account on library_stats_cubes(account_id); +create index if not exists idx_library_stats_cubes_account_updated_at on library_stats_cubes(account_id, updated_at); +create index if not exists idx_library_stats_cubes_source_updated_at on library_stats_cubes(source_updated_at); + +alter table library_stats_cubes + add column if not exists semantic_version text not null default 'library_stats_semantic/v2'; + +alter table library_stats_cubes + add column if not exists source_breakdown_json jsonb not null default '{}'; + +alter table library_stats_cubes + add column if not exists invalidated_at timestamptz; + +alter table library_stats_cubes + add column if not exists last_invalidated_event_name text; + +alter table library_stats_cubes + add column if not exists last_invalidated_event_at timestamptz; + +create index if not exists idx_library_stats_cubes_invalidated_at on library_stats_cubes(invalidated_at); + +create table if not exists generated_media_assets ( + asset_id text primary key, + asset_kind text not null, + owner_scope text not null, + owner_id text not null, + world_id text, + world_version_id text, + session_id text, + chapter_index integer, + reader_id text, + storage_bucket text, + storage_key text, + mime_type text, + width integer, + height integer, + visibility text not null default 'private', + generation_status text not null default 'queued', + model_name text, + prompt_version text, + source_fingerprint text, + prompt_trace_json json, + error text, + created_at text not null, + updated_at text not null +); + +create index if not exists idx_generated_media_assets_owner_kind_status_updated_at +on generated_media_assets (owner_scope, owner_id, asset_kind, generation_status, updated_at); + +create index if not exists idx_generated_media_assets_world_kind_status_updated_at +on generated_media_assets (world_version_id, asset_kind, generation_status, updated_at); + +create index if not exists idx_generated_media_assets_owner_fingerprint +on generated_media_assets (owner_scope, owner_id, asset_kind, source_fingerprint); diff --git a/docs/agent_studio_interactive_workbench.md b/docs/agent_studio_interactive_workbench.md new file mode 100644 index 0000000..e30ed05 --- /dev/null +++ b/docs/agent_studio_interactive_workbench.md @@ -0,0 +1,111 @@ +# Agent Studio Interactive Workbench + +Agent Studio is the default Author-side creation surface for local co-directed fiction work. It keeps the existing Author advanced tools available, but moves the first-run path into a product workflow: + +1. Set story goal, genre, length, reader experience, remix permission, and cover intent. +2. Create a local author work and generate the first chapter. +3. Read chapters in the Studio reader. +4. Choose a route card or write a director intent. +5. Create branches, switch the export main route, validate, preview, and export `.nosbook`. + +## Boundaries + +- The generation kernel remains unchanged. +- Choice semantics are generic product tags layered over existing choices. +- Reader-visible Studio copy uses product language. Internal issue codes remain in Ops/Eval diagnostics. +- Branches use AuthorWork branch family APIs and are displayed as route names such as `主线` and `路线 A:...`. + +## Layout Contract + +- Studio mode compacts the shared Author chrome so the workbench appears close to the first viewport and does not compete with the chapter. +- Wide desktop uses a reading-first two-column workbench:沉浸阅读器 / 导演台, with作品导航和路线地图 as a compact strip below the reader. +- On wide desktop, the director panel stays sticky while the user scrolls through choices and the route strip. +- Medium desktop stacks as reader, director, then route/navigation so the chapter remains the primary surface. +- Mobile stacks as reader, director, then route/navigation so the chapter remains the primary surface. +- The chapter body scrolls inside the reading page, and mobile choice cards use a bounded scroll area so director controls remain reachable. + +## Interfaces + +- Existing `reader_view.choices: string[]` remains compatible. +- Optional `reader_view.choice_impacts[]` adds risk, emotion, pacing, relationship, mystery, expected effect, and director-intent prefill. +- Route selections are persisted through `route_choices.payload_json`. +- Author works export via: + +```http +GET /v1/author/works/{work_id}/export?format=nosbook&route=active +``` + +The response is a JSON envelope with `schema_version: nosbook/v1` and content type `application/vnd.narrativeos.nosbook+json`. + +## Export Envelope + +The `.nosbook` export contains: + +- `work` +- `export_route` +- `chapters` +- `branch_map` +- `choice_history` +- `quality_summary` +- `cover` + +`route=active` exports only the active/main route chapters by default. + +## Local Launch + +Use the Studio-specific local launcher for author creation sessions: + +```bash +bash scripts/run_agent_studio_local.sh +``` + +The launcher reuses `scripts/run_backend_local.sh`, waits for `/health`, and opens: + +```text +http://127.0.0.1:8000/app?product=author&workspace=studio&debug=1 +``` + +Set `AGENT_STUDIO_OPEN_BROWSER=0` to keep the browser closed while still starting the local backend. + +## Browser Smoke + +Run the focused rendered smoke when changing Studio startup, generation, branch, or export behavior: + +```bash +CI_HEADLESS=1 bash scripts/run_agent_studio_smoke.sh +``` + +The smoke drives the real Author-side UI through startup, first chapter generation, director continuation, branch creation, and `.nosbook` export. It writes: + +- `artifacts/agent_studio_smoke_result.json` +- `artifacts/agent_studio_smoke_failure_snapshot.json` +- `artifacts/agent_studio_smoke_failure.png` +- `artifacts/agent_studio_smoke_desktop.png` +- `artifacts/agent_studio_smoke_mobile.png` +- `artifacts/agent_studio_smoke_visual_review.md` + +The result must report `schema_version: agent_studio_smoke/v1`, exported `nosbook/v1`, branch map count, choice history count, quality summary keys, screenshot file paths, mobile overflow width, desktop sticky director status, mobile choice bounded-scroll status, visual review checklist counters, generation wait copy, and `visible_q_code: false`. + +Rendered QA now captures the Studio workbench at `1440x1000` after the first chapter and at `390x844` after branch/export. The verifier checks that the reader body, director panel, branch map, and quality labels are visible, that the desktop director panel remains sticky after scrolling to choices/routes, that mobile choice cards stay in a bounded scroll area, that mobile has no horizontal overflow, and that visible Studio text does not expose internal quality codes. + +`agent_studio_smoke_visual_review.md` is a human-triage aid for the generated screenshots. Objective rows mirror smoke assertions and subjective rows are marked `manual_review`, so reviewers can inspect balance, clipping, reachability, and reader focus without adding fragile automated gates. + +When a PR changes Agent Studio layout CSS, especially `.agent-studio-*` selectors in `src/narrativeos/web/styles.css`, reviewers must paste the two `manual_review` rows into a PR comment after inspecting the screenshots: + +```md +| desktop | Three-column workbench review | manual_review | artifacts/agent_studio_smoke_desktop.png | accepted | +| mobile | Stacked workbench review | manual_review | artifacts/agent_studio_smoke_mobile.png | accepted | +``` + +Use `needs follow-up` instead of `accepted` for any visible overlap, clipped text, unreachable controls, or loss of reader prominence. This is a human review convention, not an automated image comparison gate. + +The two required row identifiers are: + +- `desktop / Three-column workbench review / manual_review` +- `mobile / Stacked workbench review / manual_review` + +Expected long-generation product copy: + +- startup: `第一章生成中` / `正在建立作品设定、人物冲突和章节正文,可能需要一两分钟。` +- continuation: `续写中` / `正在沿导演意图推进下一章,完成后会自动跳到新章节。` +- branch: `新路线创建中` / `正在从当前章节保存分支。` diff --git a/docs/frontend_reader_shell_state_contract.json b/docs/frontend_reader_shell_state_contract.json new file mode 100644 index 0000000..46f97f7 --- /dev/null +++ b/docs/frontend_reader_shell_state_contract.json @@ -0,0 +1,198 @@ +{ + "version": "2026-04-17", + "scope": "reader_shell_v2", + "shell_state": { + "fields": [ + { + "name": "activeProduct", + "type": "string", + "default": "reader", + "purpose": "current_product_surface" + }, + { + "name": "authPage", + "type": "string|null", + "default": null, + "purpose": "active_auth_route" + }, + { + "name": "debug", + "type": "boolean", + "default": false, + "purpose": "internal_debug_mode" + }, + { + "name": "startupRouteProduct", + "type": "string|null", + "default": null, + "purpose": "startup_route_product" + }, + { + "name": "startupRouteWorkspace", + "type": "string|null", + "default": null, + "purpose": "startup_route_workspace" + }, + { + "name": "readerWorkspace", + "type": "landing|read", + "default": "landing", + "purpose": "reader_workspace_mode" + }, + { + "name": "lastReaderView", + "type": "experience|storybook|backstage", + "default": "experience", + "purpose": "pre_backstage_reader_view" + } + ] + }, + "reader_shell_state": { + "fields": [ + { + "name": "worldId", + "type": "string|null", + "default": null, + "purpose": "current_world_id" + }, + { + "name": "worldVersionId", + "type": "string|null", + "default": null, + "purpose": "current_world_version_id" + }, + { + "name": "readerId", + "type": "string", + "default": "reader_demo", + "purpose": "reader_or_account_identifier" + }, + { + "name": "readerAuthSession", + "type": "object|null", + "default": null, + "purpose": "reader_auth_session" + }, + { + "name": "sessionId", + "type": "string|null", + "default": null, + "purpose": "active_reader_session" + }, + { + "name": "currentBundle", + "type": "object|null", + "default": null, + "purpose": "active_example_or_world_bundle" + }, + { + "name": "sessionLibrary", + "type": "array", + "default": [], + "purpose": "restorable_reader_sessions" + }, + { + "name": "authoredWorkLibrary", + "type": "array", + "default": [], + "purpose": "author_work_jump_list" + }, + { + "name": "currentState", + "type": "object|null", + "default": null, + "purpose": "current_narrative_state" + }, + { + "name": "latestStep", + "type": "object|null", + "default": null, + "purpose": "latest_successful_reader_step" + }, + { + "name": "latestStepFailure", + "type": "object|null", + "default": null, + "purpose": "latest_failed_reader_step" + }, + { + "name": "continuityContract", + "type": "object|null", + "default": null, + "purpose": "reader_continuity_contract" + }, + { + "name": "intentPrefill", + "type": "object|null", + "default": null, + "purpose": "reader_prefill_payload" + }, + { + "name": "replay", + "type": "object|null", + "default": null, + "purpose": "reader_replay_payload" + }, + { + "name": "sessionMedia", + "type": "object", + "default": { + "coverImage": "", + "atmosphereImage": "" + }, + "purpose": "current_session_cover_and_chapter_illustration_urls" + }, + { + "name": "readerEntitlements", + "type": "array", + "default": [], + "purpose": "entitlement_listing" + }, + { + "name": "readerSubscription", + "type": "object|null", + "default": null, + "purpose": "subscription_payload" + }, + { + "name": "readerCheckoutSession", + "type": "object|null", + "default": null, + "purpose": "latest_checkout_session" + }, + { + "name": "pendingCheckoutContext", + "type": "object|null", + "default": null, + "purpose": "checkout_return_restore_context" + }, + { + "name": "activeView", + "type": "experience|storybook|backstage", + "default": "experience", + "purpose": "reader_subview" + } + ] + }, + "invariants": [ + "When activeProduct is not reader, Reader shell may retain state but must not become the active surface.", + "When readerWorkspace is landing, sessionId is optional.", + "When readerWorkspace is read, rendering may be driven by latestStep or latestStepFailure or currentBundle plus sessionId.", + "sessionMedia.coverImage and sessionMedia.atmosphereImage are progressive enhancement fields; missing image URLs must retain text and gradient fallback rendering.", + "When continuityContract.status is payment_required, the shell must preserve the current session path.", + "When continuityContract.status is quality_guard_failed, the shell must preserve the current session context and expose a retry path.", + "pendingCheckoutContext must be sufficient to restore accountId, sessionId, readerWorkspace, and activeView." + ], + "legacy_reader_state_fields": [ + "shelfWorlds", + "latestPreview", + "selectedIntentOverride", + "selectedReplayIndex", + "sessionPaywall", + "pendingCheckoutSessionId", + "pendingCheckoutStatus", + "continuityDiagnostics", + "activeTone", + "activeAuthoredWorkPreview" + ] +} diff --git a/docs/frontend_reader_shell_state_contract.md b/docs/frontend_reader_shell_state_contract.md new file mode 100644 index 0000000..7809bd2 --- /dev/null +++ b/docs/frontend_reader_shell_state_contract.md @@ -0,0 +1,82 @@ +# Frontend Reader Shell State Contract + +版本:2026-04-17 + +目的: +- 为 Reader shell v2 提供最小状态合同 +- 把当前 Reader 壳层从巨型共享状态里收窄到可替换 shell 所需的最小面 + +## ShellState + +仅保留以下字段: + +| 字段 | 类型 | 默认值 | 用途 | +| --- | --- | --- | --- | +| `activeProduct` | `string` | `reader` | 当前产品视图 | +| `authPage` | `string \| null` | `null` | 当前 auth route | +| `debug` | `boolean` | `false` | debug/internal mode | +| `startupRouteProduct` | `string \| null` | `null` | 启动时路由 product | +| `startupRouteWorkspace` | `string \| null` | `null` | 启动时路由 workspace | +| `readerWorkspace` | `landing \| read` | `landing` | Reader 工作区 | +| `lastReaderView` | `experience \| storybook \| backstage` | `experience` | 离开 backstage 前的视图 | + +## ReaderShellState + +仅保留以下字段: + +| 字段 | 类型 | 默认值 | 用途 | +| --- | --- | --- | --- | +| `worldId` | `string \| null` | `null` | 当前世界 | +| `worldVersionId` | `string \| null` | `null` | 当前世界版本 | +| `readerId` | `string` | `reader_demo` | 当前 reader/account 标识 | +| `readerAuthSession` | `object \| null` | `null` | Reader 登录态 | +| `sessionId` | `string \| null` | `null` | 当前 Reader session | +| `currentBundle` | `object \| null` | `null` | 当前 world/example bundle | +| `sessionLibrary` | `array` | `[]` | 可恢复 session 列表 | +| `authoredWorkLibrary` | `array` | `[]` | 作者作品入口列表 | +| `currentState` | `object \| null` | `null` | 当前 narrative state | +| `latestStep` | `object \| null` | `null` | 最近成功推进的章节结果 | +| `latestStepFailure` | `object \| null` | `null` | 最近失败推进结果 | +| `continuityContract` | `object \| null` | `null` | Reader continuity contract | +| `intentPrefill` | `object \| null` | `null` | 推荐起笔句与压力提示 | +| `replay` | `object \| null` | `null` | replay payload | +| `sessionMedia` | `object` | `{ coverImage: "", atmosphereImage: "" }` | 当前 session 的封面与章节插图 URL | +| `readerEntitlements` | `array` | `[]` | Reader entitlement 列表 | +| `readerSubscription` | `object \| null` | `null` | Reader subscription payload | +| `readerCheckoutSession` | `object \| null` | `null` | 最近 checkout session | +| `pendingCheckoutContext` | `object \| null` | `null` | checkout return 恢复上下文 | +| `activeView` | `experience \| storybook \| backstage` | `experience` | 当前 Reader 子视图 | + +## 状态不变式 + +- `activeProduct !== "reader"` 时,Reader shell 只保留状态,不主动渲染 +- `readerWorkspace === "landing"` 时,不要求存在 `sessionId` +- `readerWorkspace === "read"` 时,允许以下任一状态驱动渲染: + - `latestStep` + - `latestStepFailure` + - `sessionId + currentBundle` +- `sessionMedia.coverImage` / `sessionMedia.atmosphereImage` 为渐进增强字段,缺失时 Reader 必须保留文字/渐变 fallback +- `readerWorkspace === "read"` 且 Reader shell v2 启用时,`#reader-shell-v2` 必须移除 `is-hidden` 并真实显示 Storybook / Experience 内容,不能只把 replay 正文渲染进隐藏 DOM +- `continuityContract.status === "payment_required"` 时,必须保留当前 session 路径 +- `continuityContract.status === "quality_guard_failed"` 时,必须保留当前 session、上一章上下文和 retry path +- `pendingCheckoutContext` 必须至少能恢复: + - `accountId` + - `sessionId` + - `readerWorkspace` + - `activeView` + +## Legacy 字段 + +以下 Reader 旧状态字段视为 legacy,不进入 v2 首版合同: +- `shelfWorlds` +- `latestPreview` +- `selectedIntentOverride` +- `selectedReplayIndex` +- `sessionPaywall` +- `pendingCheckoutSessionId` +- `pendingCheckoutStatus` +- `continuityDiagnostics` +- `activeTone` +- `activeAuthoredWorkPreview` + +这些字段可以暂时继续存在于旧 runtime,但新 Reader shell 首版不应依赖它们作为核心合同。 diff --git a/docs/frontend_shell_rebuild.md b/docs/frontend_shell_rebuild.md new file mode 100644 index 0000000..f6a8cbc --- /dev/null +++ b/docs/frontend_shell_rebuild.md @@ -0,0 +1,437 @@ +# NarrativeOS Frontend Shell Rebuild + +## What Changed + +The web shell now treats `Reader`, `Author`, and `Ops` as separate product workspaces instead of one long scrolling console. + +- `Reader` defaults to a `Landing / Shelf` experience with three top-level actions: + - continue a prior journey + - browse worlds + - start a new journey +- `Reader` switches into a dedicated reading workspace after a session is created or restored. +- `Author` is grouped into guided workspaces: + - `Overview` + - `Brief` + - `Draft` + - `Simulate` + - `Review & Submit` + - `Settings` +- `Ops` is grouped into task-oriented workspaces: + - `Dashboard` + - `Review Queue` + - `Account Investigation` + - `Release Workspace` + - `Alerts & Governance` + - `Learned / Infra` + +## Reader Polish + +The Reader surface now follows the `Landing -> Read -> Storybook / Backstage` progression more explicitly. + +- Landing now foregrounds: + - continue a prior journey + - browse worlds + - start a new journey +- Landing shows a compact context summary for: + - current world + - current reader id + - whether resumable sessions exist +- World cards now externalize tacit context: + - theme / mood + - recommended play style + - access state +- Session cards now emphasize the safe continue path first. `继续阅读` is the only primary action. +- Starting a session now enters a readable `序章` state instead of a blank reading workspace. +- Storybook now renders in an editorial order: + - title + - recap + - quote + - prose + - beats + - tags + - timeline +- Reader shell v2 的 Storybook 进一步改成高保真章节画布: + - 顶部先外显章节编号、当前 turn、未解牵挂和画面母题 + - 正文与引句分栏,避免 recap / quote / prose 混成一块 + - 节拍改成编号列表,先回答“这一章怎么抬势、落子、留余波” + - 连续章节轨迹改成可点击回看卡,允许在最近几章之间直接切换 +- Paywall and unlock are now rendered as chapter-inline unlock cards instead of standalone system banners. + - the last readable chapter carries the unlock explanation + - the CTA continues the current reading path instead of bouncing the user to a separate control surface + - the same unlock card appears inside `Storybook` when a chapter is blocked +- Backstage has a dedicated `返回阅读` action and behaves like a contextual analysis drawer instead of a permanent third-column console. + +## Reader Continuity Contract + +Reader 继续阅读现在统一围绕四种可恢复状态组织: + +- `ok` +- `payment_required` +- `quality_guard_failed` +- `restricted` + +界面行为约束: + +- 只要存在 `session_id / pending checkout / quality guard failure / authored work preview`,Reader 都必须保持在 `workspace=read` +- `payment_required` 不再把用户弹回 landing;主动作是“解锁并继续当前 session” +- `quality_guard_failed` 不再被当成“读坏了”;主动作是“重试当前章”,同时保留当前 session、上一章内容和当前阅读视图 +- `Backstage` 关闭后回到上一个阅读视图,而不是固定回 `experience` + +## Author Polish + +The Author surface now behaves more like a daily workbench and less like a raw panel dump. + +- Overview now foregrounds a `推荐下一步` focus block instead of forcing the author to scan all panels equally. +- The top Author hero now adapts to context: + - no draft: guide the author into `Brief` + - active draft present: guide the author into `Draft 摘要` or `Simulate` +- Brief is now grouped into three clear composer clusters: + - 世界与关系 + - 命题与冲突 + - 氛围与地点 + - the only primary action in this workspace is `根据 Brief 生成 Draft` +- Draft now has a local subsection switch instead of a flat panel dump: + - `Assets` + - `Longform` + - `Repair` + - `Style` + - the subsection rail stays visible while individual panels swap beneath it +- Workflow is now rendered as a structured work card: + - current stage + - recommended next action + - validation/simulation health + - blocker summary + - stage pills +- Draft detail is now rendered as a scan-friendly summary instead of a raw multiline dump: + - project positioning + - current health + - run-state + - narrative style + - diagnosis +- Settings now exposes three explicit summary cards at the top: + - auth state + - notification state + - collaboration state + - each card now carries a current anomaly or reminder plus a primary action +- Simulate now exposes work cards instead of only jump buttons: + - chapter task work card + - continuity work card + - compare work card + - each work card now shows current object, current problem, and the recommended next edit path +- Simulate and Review now each start with a summary dock: + - Simulate foregrounds `latest decision / freshness / issue queue / weakest chapter` + - Review foregrounds `readiness / compare evidence / revision stack` +- Author interactions now prefer shell status banners and toasts over blocking alerts for common missing-field and action-failed feedback. + +## Author Validation Routes + +Use these routes when validating the Author shell by hand: + +- `?product=author&workspace=overview` +- `?product=author&workspace=brief` +- `?product=author&workspace=draft` +- `?product=author&workspace=simulate` +- `?product=author&workspace=review` +- `?product=author&workspace=settings` + +## Public Vs Internal Shell + +The shell now distinguishes between: + +- Public mode: the default `/app` experience for user-facing surfaces + - only `阅读 / 创作` are visible in the top product switcher + - `运营` and `内部模式` controls stay hidden + - public-facing banners and cards should avoid raw engineering errors, ids, provider names, or untranslated product terms +- Internal mode: `?debug=1` + - `运营` becomes visible again + - smoke verification and operator-only tools continue to run here + - deep Ops cards are still expected to render in Chinese, even when they expose internal workflow detail + +The repo now keeps these browser checks: + +- Reader-only smoke: `scripts/run_reader_shell_smoke.sh` +- Internal end-to-end smoke: `scripts/run_frontend_shell_smoke.sh` +- Agent Studio workbench smoke: `scripts/run_agent_studio_smoke.sh` +- Public copy guard: `scripts/run_public_shell_copy_check.sh` +- Unified internal Ops browser guard entry: `scripts/run_ops_internal_browser_guards.sh` +- Internal Ops deep-card guard: `scripts/run_ops_internal_snapshot_check.sh` +- Internal Ops form-copy guard: `scripts/run_ops_internal_form_copy_check.sh` +- Internal Ops static-copy guard: `scripts/run_ops_internal_static_copy_check.sh` +- Internal Ops populated-card guard: `scripts/run_ops_internal_populated_copy_check.sh` +- Internal Ops account/alert/governance populated guard: `scripts/run_ops_internal_account_copy_check.sh` + +The recommended CI entry for internal Ops browser verification is now: + +- workflow: `.github/workflows/ops-internal-browser-guards.yml` +- local runner: `scripts/run_ops_internal_browser_guards.sh` +- summary writer: `scripts/write_ops_internal_browser_guard_summary.py` + +The unified runner executes the internal Ops browser guards in one place with staggered ports, so CI and local verification no longer need to remember each individual script manually. +The workflow now also publishes a GitHub step summary in the same style as `frontend-shell-smoke`, including: + +- overall status +- per-guard completed/failed steps +- failure snapshot references +- server log tails +- chrome log tails + +All browser guard result files now target the same lightweight schema family: + +- `schema_version` +- `guard` +- `summary_meta` +- `artifacts` +- `status / completed_steps / failed_step / console_errors / summary` + +This keeps `public shell`, `frontend shell smoke`, and `internal Ops guards` ready to merge into one QA dashboard without extra result-shape conversion. + +## Agent Studio QA Rails + +Use the Agent Studio smoke when validating the Author-side co-directed fiction workbench: + +- local author launcher: `scripts/run_agent_studio_local.sh` opens `/app?product=author&workspace=studio&debug=1` after the backend health check passes +- local runner: `scripts/run_agent_studio_smoke.sh` +- CI/headless form: `CI_HEADLESS=1 CHROME_BIN=/path/to/google-chrome bash scripts/run_agent_studio_smoke.sh` +- artifacts: + - `artifacts/agent_studio_smoke_result.json` + - `artifacts/agent_studio_smoke_failure_snapshot.json` + - `artifacts/agent_studio_smoke_failure.png` + - `artifacts/agent_studio_smoke_desktop.png` + - `artifacts/agent_studio_smoke_mobile.png` + - `artifacts/agent_studio_smoke_visual_review.md` + +The smoke verifies startup, first chapter generation, director continuation, route branching, `.nosbook` export, visible product-language wait copy, desktop workbench rendering at `1440x1000`, desktop sticky director behavior after scrolling to choices/routes, mobile workbench rendering at `390x844`, mobile bounded choice-card scrolling, mobile horizontal overflow, and a visual review checklist for screenshot triage. + +The visual review checklist combines automatic evidence rows with `manual_review` prompts for layout balance, overlap, reader prominence, mobile readability, control reachability, and clipped text. Only objective smoke checks can fail the run. + +For PRs that change Agent Studio layout CSS, reviewers must paste the two `manual_review` rows from `artifacts/agent_studio_smoke_visual_review.md` into a PR comment and mark each as `accepted` or `needs follow-up` after screenshot inspection: + +- `desktop / Three-column workbench review / manual_review` +- `mobile / Stacked workbench review / manual_review` + +This convention is human visual triage only; it does not add an automated image comparison gate. + +## Reader QA Rails + +Use the Reader-only smoke when we need a clean regression signal for the reading surface without Author/Ops coupling: + +- local runner: `scripts/run_reader_shell_smoke.sh` +- CI/headless form: `CI_HEADLESS=1 CHROME_BIN=/path/to/google-chrome bash scripts/run_reader_shell_smoke.sh` +- artifacts: + - `artifacts/reader_shell_smoke_result.json` + - `artifacts/reader_shell_smoke_failure_snapshot.json` + - `artifacts/reader_shell_smoke_failure.png` + +The Reader-only smoke verifies: + +- `Landing -> Create Session -> Restore Session` +- first Reader step and inline paywall rendering +- checkout completion and resume into the same session +- `Storybook / Backstage` view switching after the session is active + +Use the long-route Reader storybook smoke when we need to check chapter-canvas stability after 30–50 chapters instead of only the first few turns: + +- local runner: `bash scripts/run_reader_storybook_long_route_smoke.sh` +- CI/headless form: `CI_HEADLESS=1 CHROME_BIN=/path/to/google-chrome bash scripts/run_reader_storybook_long_route_smoke.sh` +- CI workflow: `.github/workflows/reader-storybook-long-route-smoke.yml` +- defaults: + - `WORLD_IDS=jade_court_exam,jade_court_romance,urban_mystery_lotus_lane` + - `TARGET_CHAPTERS=30` + - `MIN_TARGET_CHAPTERS=30` +- artifacts: + - `artifacts/reader_storybook_long_route_smoke_seed.json` + - `artifacts/reader_storybook_long_route_smoke_result.json` + - `artifacts/reader_storybook_long_route_smoke_history.json` + - `artifacts/reader_storybook_long_route_smoke_failure_snapshot.json` + - `artifacts/reader_storybook_long_route_smoke_failure.png` + - `artifacts/reader_storybook_long_route_smoke_storybook.png` + +The long-route Reader storybook smoke verifies: + +- seeded Reader sessions can really reach the `30 / 30` long-route target window on at least `jade_court_exam` / `jade_court_romance` / `urban_mystery_lotus_lane` +- `Storybook` trajectory no longer stays in single-chapter mode after long-route accumulation +- each seeded pack is restored and verified independently by `session_id` +- sampled early / middle / latest visible trajectory cards in each seeded pack all render non-placeholder `quote` +- sampled early / middle / latest visible trajectory cards in each seeded pack all render non-empty `beats` +- every non Jade Court pack must keep a minimum difference from Jade Court packs: + - if sampled titles are highly similar, sampled quote-token overlap must still stay below the smoke threshold + - the smoke currently records `title_similarity / quote_similarity / passes_min_difference` +- title homogenization is also emitted as a non-blocking warning: + - when sampled titles stay nearly identical across packs but quote-token overlap is still low + - the smoke records `reader_storybook_title_homogenization_warnings / warning_count` +- repeated title-homogenization warnings are persisted into `reader_storybook_long_route_smoke_history.json` + - after `3` consecutive eligible runs for the same non-Jade vs Jade pair, the trend is promoted into release review as a watch-only checklist observation + - the smoke records `reader_storybook_title_homogenization_history_summary / trend / promoted_pairs` + - the CI workflow restores the latest non-expired `reader-storybook-long-route-smoke-history` artifact before the run, then uploads the appended history again after the run +- a success screenshot is captured from the long-route storybook surface for visual QA + +The public copy guard verifies: + +- `debug=0` hides Ops and internal controls +- payment card copy stays user-facing +- reader left rail copy stays user-facing +- Author `overview / brief / draft / simulate / review / settings` first-screen copy stays user-facing +- a forbidden-terms list does not reappear in visible public text + +The internal Ops deep-card guard verifies: + +- `debug=1&product=ops` can render internal Ops mode cleanly +- mocked deep Ops cards still render Chinese field labels instead of old English headings +- the remaining deep-detail containers stay guarded: + - runtime receipts + - provider runtime metrics + - governance export + - investigation timeline + - learned compare + - evaluator / reranker promotion gates + - learned data ops summary and backlog/detail cards + +The internal Ops form-copy guard verifies: + +- `debug=1&product=ops` 下的表单区标签、按钮、选项和 placeholder 都维持中文 +- 重点覆盖: + - 统一导航 + - 发布台 + - 账户 / 订阅 / 钱包操作区 + - 告警、治理、统一排查 + - 辅助门控 / 辅助重排 / 发布审批 + - 人工审阅、偏好采集、排序采集 + - 数据一致性、备份恢复、异步任务、通道灰度 +- `Account ID / Reviewer ID / World Version ID / Restriction Type` 这类旧英文标签不会回退到可见界面 + +The internal Ops static-copy guard verifies: + +- `debug=1&product=ops` 下默认空态的说明区仍然是中文 +- 重点覆盖: + - 发布台与账户排查空态 + - 告警、治理、统一排查说明区 + - 学习层总览、实验、灰度、发布门、待补样区 + - 跨包基准、审核历史、质量趋势 + - 数据库结构、运行手册、异步任务、运行观测 +- `world version / trace timeline / learned summary / runtime incident snapshot / cost trend dashboard` 这类旧英文说明不会回退到可见界面 + +The internal Ops populated-card guard verifies: + +- `debug=1&product=ops` 下带数据的运营卡片正文仍然保持中文 +- 重点覆盖: + - 审核队列与世界状态 + - 运行时事故快照、通道路由、通道灰度、通道运行指标 + - 排查摘要与证据索引 + - 质量评测卡片与跨包基准卡片 +- `latest / summary / provider / trace / issue / cross-pack / publish gate` 这类旧英文正文不会回退到真实数据卡片里 + +The internal Ops account/alert/governance populated guard verifies: + +- `debug=1&product=ops` 下账户、告警、治理三组带数据详情卡片正文仍然保持中文 +- 重点覆盖: + - 订阅审计 / 生命周期时间线 / 账户运营时间线 + - 客服问题定位卡片 + - 告警列表与告警详情 + - 治理摘要 / 治理个案 / 治理详情 / 审计导出 + - 账户审计拆解 / 审计轨迹 +- `subscriptions / provider / linked cases / target / owner / policy / evidence / audit breakdown / actor / wallet / tier` 这类旧英文正文不会回退到用户可见详情卡片里 + +## Routing Model + +The shell now syncs primary UI state into the URL query string. + +- `product` +- `workspace` +- `view` +- `world_id` +- `session_id` +- `draft_id` +- `account_id` +- `debug` + +Examples: + +- Reader landing: `?product=reader&workspace=landing` +- Reader reading view: `?product=reader&workspace=read&view=experience&session_id=...` +- Author review workspace: `?product=author&workspace=review&draft_id=...` +- Ops account investigation: `?product=ops&workspace=account&account_id=...` + +## UX Rules + +- Internal debug and entitlement testing controls are hidden by default. +- Reader entitlement, subscription, and checkout requests only run when needed: + - debug mode is enabled + - a paywall quote exists + - a checkout flow is already in progress +- Blocking `alert()` dialogs are replaced with non-blocking status banners and toasts. +- Mobile keeps a single primary pane visible at a time. +- Reader backstage analysis no longer lives as a permanent third column. + +## Implementation Notes + +- `src/narrativeos/web/index.html` now owns the new shell header, landing entry, and cache-busted asset references. +- `src/narrativeos/web/state_runtime.js` now owns the explicit shared state boundary: + - `shellState` + - `readerState` + - `authorState` + - `opsState` +- `src/narrativeos/web/ui_shared.js` now owns UI/transport helpers such as API fetch/error handling, status/toast messaging, generic card utilities, formatting, and parse helpers. +- `src/narrativeos/web/reader_accessors.js` now owns membership/gating/unlock accessors used by Reader and any surface that needs reader-facing entitlement labels. +- `src/narrativeos/web/author_accessors.js` now owns draft/workbench accessors and author-specific gating helpers. +- `src/narrativeos/web/ops_accessors.js` now owns Ops-only state accessors such as async job lookup. +- `src/narrativeos/web/route_sync_runtime.js` now owns shell URL/query synchronization, including product/workspace/view/account/draft/session route hydration and replacement. +- `src/narrativeos/web/workspace_layout_runtime.js` now owns workspace grouping, subnav rendering, workspace switching, and scroll-to-workspace bridging. +- `src/narrativeos/web/shell_status_runtime.js` now owns shell status/UI synchronization such as `syncViewMode`, `syncProductMode`, `updateStatus`, and debug-toggle side effects. +- `src/narrativeos/web/shell_bootstrap_runtime.js` now owns the classic-script exposure layer, wiring runtime exports into the shared global names that older scripts still call. +- `src/narrativeos/web/dom_shared.js` now owns the DOM query helpers and `NULL_NODE` fallback used by each DOM runtime directly. +- `src/narrativeos/web/shell_dom.js` now owns shell-scoped DOM lookups. +- `src/narrativeos/web/reader_dom.js` now owns Reader-scoped DOM lookups, including Storybook templates and tone pills. +- `src/narrativeos/web/author_dom.js` now owns Author-scoped DOM lookups. +- `src/narrativeos/web/ops_dom.js` now owns Ops-scoped DOM lookups. +- `src/narrativeos/web/reader.js` now owns Reader runtime behavior, reading flow, Storybook rendering, unlock cards, and Reader bootstrapping. +- `src/narrativeos/web/author_workspace.js` now owns Author workspace behavior, rendering, workflow actions, and guided workspace transitions. +- `src/narrativeos/web/ops_shared.js` now owns shared Ops helpers used by both `ops_actions.js` and `ops_render_sections.js`. +- `src/narrativeos/web/ops_refresh.js` now exports `OpsRefreshRuntime`, which owns Ops refresh scopes, navigation context sync, and scoped surface reload flows. +- `src/narrativeos/web/ops_actions.js` now exports `OpsActionsRuntime`, which owns Ops action handlers instead of leaking action names through implicit global script scope. +- `src/narrativeos/web/ops_render_sections.js` now exports `OpsRenderRuntime`, which owns Ops section rendering and consumes refresh/actions through explicit runtime boundaries. +- `src/narrativeos/web/ops_runtime.js` now owns the remaining Ops runtime form submissions that used to live in `app.js`, including review capture, preference capture, and ranking capture flows. +- `src/narrativeos/web/reader.js` now exposes `bindReaderEvents()` so Reader-specific listeners stay with Reader behavior and rendering. +- `src/narrativeos/web/author_workspace.js` now exposes `bindAuthorWorkspaceEvents()` and `initializeAuthorWorkspaceRuntime()` so Author listeners and auth/session boot stay with the Author surface. +- `src/narrativeos/web/ops_runtime.js` now exposes `bindOpsEvents()` and `initializeOpsRuntime()` so Ops listeners stay with Ops refresh/action runtimes instead of leaking back into shell bootstrap. +- `src/narrativeos/web/shell_runtime.js` now owns only top-level shell startup order, product mode switching, debug toggle wiring, and runtime boot orchestration. It no longer carries product-specific listener lists. +- `src/narrativeos/web/app.js` has been removed from the `/app` runtime chain. DOM registration now lives in `dom_shared` plus `shell_dom / reader_dom / author_dom / ops_dom`. +- The repo now includes a minimal cross-surface smoke harness for `/app`: + - local runner: `scripts/run_frontend_shell_smoke.sh` + - browser verification: `scripts/verify_frontend_shell_smoke.js` + - CI workflow: `.github/workflows/frontend-shell-smoke.yml` + - current flow coverage: + - Reader enters a world and advances one step + - Reader can be forced into a paid chapter, show an inline unlock card, create checkout, simulate webhook activation, and resume reading + - Author refreshes once, registers/logs in a smoke actor, creates a real draft from brief, then runs a real simulate + - Ops switches `Dashboard -> Review Queue -> Account Investigation`, grants one `creator_pass` subscription mutation for the smoke actor, creates one real governance case on that account, pushes it into `in_review`, appends one real evidence note, applies one real `checkout_block` restriction, releases that restriction, then assigns an explicit owner and resolves the original case as that owner +- Agent Studio now has a focused rendered smoke for the co-directed workbench: + - local runner: `bash scripts/run_agent_studio_smoke.sh` + - browser verification: `scripts/verify_agent_studio_smoke.js` + - summary: `scripts/write_agent_studio_smoke_step_summary.py` + - artifacts: `artifacts/agent_studio_smoke_result.json`, `artifacts/agent_studio_smoke_failure_snapshot.json`, `artifacts/agent_studio_smoke_failure.png`, `artifacts/agent_studio_smoke_desktop.png`, `artifacts/agent_studio_smoke_mobile.png`, `artifacts/agent_studio_smoke_visual_review.md` + - flow coverage: startup page -> first chapter -> director continuation -> branch creation -> `.nosbook` export + - layout regression coverage: desktop sticky director and mobile bounded choice-card scrolling +- Startup flow now resolves as: + - `DOMShared` + - `ShellDOM` + - `ReaderDOM` + - `AuthorDOM` + - `OpsDOM` + - `StateRuntime` + - `UIShared` + - `ReaderAccessors` + - `AuthorAccessors` + - `OpsAccessors` + - `OpsShared` + - `OpsRefreshRuntime` + - `OpsActionsRuntime` + - `OpsRenderRuntime` + - `OpsRuntime` + - `ReaderRuntime` + - `AuthorWorkspaceRuntime` + - `RouteSyncRuntime` + - `WorkspaceLayoutRuntime` + - `ShellStatusRuntime` + - `ShellBootstrapRuntime` + - `ShellRuntime.initializeShellRuntime()` +- `src/narrativeos/web/ops_refresh.js` still defers expensive account/runtime/release requests until the matching workspace is active, but those refresh paths are now consumed through `OpsRefreshRuntime` instead of bare cross-file globals. diff --git a/narrativeos_codex_execution_dossier/03_CODEX_PR_REVIEW_TEMPLATE.md b/narrativeos_codex_execution_dossier/03_CODEX_PR_REVIEW_TEMPLATE.md index 38ad7f1..c3cd75b 100644 --- a/narrativeos_codex_execution_dossier/03_CODEX_PR_REVIEW_TEMPLATE.md +++ b/narrativeos_codex_execution_dossier/03_CODEX_PR_REVIEW_TEMPLATE.md @@ -14,6 +14,12 @@ - weakest pack delta: - cross-pack pass-rate delta: - issue category delta (Q03/Q04/Q05/Q09 if relevant): +- Agent Studio layout CSS touched: [ ] yes / [ ] no +- Agent Studio visual review: + - If yes, Agent Studio layout-facing CSS changed, especially `src/narrativeos/web/styles.css` selectors for `.agent-studio-*`. + - Reviewers must paste the two `manual_review` rows from `artifacts/agent_studio_smoke_visual_review.md` into PR comments. + - Required rows: `desktop / Three-column workbench review / manual_review` and `mobile / Stacked workbench review / manual_review`. + - Each pasted row must be marked `accepted` or `needs follow-up` after screenshot inspection. - rollback point: - next suggested task: @@ -31,6 +37,9 @@ Reject if any are true: - lane scope violated - rollback / recovery is unclear - result is mostly prose tuning rather than system capability +- Agent Studio layout CSS changed and the PR lacks the pasted `manual_review` rows from `artifacts/agent_studio_smoke_visual_review.md` + +Agent Studio visual review is human screenshot triage, not an automated image comparison gate. ## Merge decision - approve / request changes / reject diff --git a/scripts/check_database_env.py b/scripts/check_database_env.py new file mode 100755 index 0000000..3c25486 --- /dev/null +++ b/scripts/check_database_env.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.narrativeos.persistence.preflight import database_preflight, public_beta_database_readiness +from src.narrativeos.runtime_env import describe_env_sources + + +def _text_report(payload: dict) -> str: + lines = [ + f"database_kind: {payload.get('database_kind')}", + f"database_url: {payload.get('database_url_redacted') or '-'}", + ] + if payload.get("host"): + lines.append(f"host: {payload.get('host')}") + if payload.get("database_name"): + lines.append(f"database: {payload.get('database_name')}") + lines.append(f"ok: {payload.get('ok')}") + lines.append(f"reason: {payload.get('reason')}") + if payload.get("error"): + lines.append(f"error: {payload.get('error')}") + if payload.get("reason") == "authentication_failed": + lines.extend( + [ + "remediation:", + "- run `npx vercel env pull .env.local --environment=development`", + "- rerun this preflight", + "- if it still fails, rotate the Neon/Vercel database password and refresh DATABASE_URL/DATABASE_URL_UNPOOLED/PGPASSWORD", + ] + ) + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Check current DATABASE_URL connectivity.") + parser.add_argument("--database-url", default=None, help="Override DATABASE_URL for this preflight run.") + parser.add_argument("--direct-database-url", default=None, help="Override DATABASE_URL_UNPOOLED for Public Beta readiness.") + parser.add_argument("--read-replica-url", default=None, help="Override read-replica URL for Public Beta readiness.") + parser.add_argument("--public-beta", action="store_true", help="Run Public Beta pooled/direct/read-replica/failover checks.") + parser.add_argument("--skip-live-probe", action="store_true", help="Skip the live database connection probe.") + parser.add_argument("--format", choices=("text", "json"), default="text") + args = parser.parse_args() + + if args.public_beta: + payload = public_beta_database_readiness( + database_url=args.database_url, + direct_database_url=args.direct_database_url, + read_replica_url=args.read_replica_url, + load_env_files=True, + run_live_probe=not args.skip_live_probe, + ) + else: + payload = database_preflight(database_url=args.database_url, load_env_files=True) + payload["env_sources"] = describe_env_sources() + + if args.format == "json": + print(json.dumps(payload, ensure_ascii=False, indent=2)) + else: + if args.public_beta: + print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)) + else: + print(_text_report(payload)) + + return 0 if (payload.get("ready") if args.public_beta else payload.get("ok")) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_agent_studio_local.sh b/scripts/run_agent_studio_local.sh new file mode 100755 index 0000000..04c5de0 --- /dev/null +++ b/scripts/run_agent_studio_local.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +if [ -f ".env.local" ]; then + # shellcheck disable=SC1091 + source ".env.local" +fi +if [ -f ".env" ]; then + # shellcheck disable=SC1091 + source ".env" +fi + +APP_HOST="${APP_HOST:-127.0.0.1}" +APP_PORT="${APP_PORT:-8000}" +AGENT_STUDIO_URL="${AGENT_STUDIO_URL:-http://${APP_HOST}:${APP_PORT}/app?product=author&workspace=studio&debug=1}" +AGENT_STUDIO_OPEN_BROWSER="${AGENT_STUDIO_OPEN_BROWSER:-1}" +AGENT_STUDIO_OPEN_TIMEOUT_SECONDS="${AGENT_STUDIO_OPEN_TIMEOUT_SECONDS:-90}" + +open_agent_studio_url() { + echo "agent_studio_frontend:${AGENT_STUDIO_URL}" + if [ "${AGENT_STUDIO_OPEN_BROWSER}" = "0" ]; then + return 0 + fi + + if command -v open >/dev/null 2>&1; then + open "${AGENT_STUDIO_URL}" >/dev/null 2>&1 || true + return 0 + fi + if command -v xdg-open >/dev/null 2>&1; then + xdg-open "${AGENT_STUDIO_URL}" >/dev/null 2>&1 || true + return 0 + fi + if command -v python3 >/dev/null 2>&1; then + python3 -m webbrowser "${AGENT_STUDIO_URL}" >/dev/null 2>&1 || true + fi +} + +wait_for_backend_and_open() { + local deadline + deadline=$((SECONDS + AGENT_STUDIO_OPEN_TIMEOUT_SECONDS)) + while [ "$SECONDS" -le "$deadline" ]; do + if curl -sf "http://${APP_HOST}:${APP_PORT}/health" >/dev/null 2>&1; then + open_agent_studio_url + return 0 + fi + sleep 1 + done + echo "agent_studio_frontend_timeout:${AGENT_STUDIO_URL}" >&2 + return 0 +} + +if curl -sf "http://${APP_HOST}:${APP_PORT}/health" >/dev/null 2>&1; then + open_agent_studio_url + exit 0 +fi + +wait_for_backend_and_open & +exec bash scripts/run_backend_local.sh diff --git a/scripts/run_agent_studio_smoke.sh b/scripts/run_agent_studio_smoke.sh new file mode 100755 index 0000000..4b58146 --- /dev/null +++ b/scripts/run_agent_studio_smoke.sh @@ -0,0 +1,150 @@ +#!/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/agent_studio_smoke.db" +RESULT_FILE="${ROOT_DIR}/artifacts/agent_studio_smoke_result.json" +FAILURE_ARTIFACT_FILE="${ROOT_DIR}/artifacts/agent_studio_smoke_failure_snapshot.json" +FAILURE_SCREENSHOT_FILE="${ROOT_DIR}/artifacts/agent_studio_smoke_failure.png" +DESKTOP_SCREENSHOT_FILE="${ROOT_DIR}/artifacts/agent_studio_smoke_desktop.png" +MOBILE_SCREENSHOT_FILE="${ROOT_DIR}/artifacts/agent_studio_smoke_mobile.png" +VISUAL_REVIEW_FILE="${ROOT_DIR}/artifacts/agent_studio_smoke_visual_review.md" +APP_PORT="${APP_PORT:-8018}" +APP_URL="${APP_URL:-http://127.0.0.1:${APP_PORT}/app?product=author&workspace=studio&debug=1}" +CHROME_PORT="${CHROME_PORT:-9238}" +CHROME_USER_DIR="${CHROME_USER_DIR:-/tmp/narrativeos-chrome-agent-studio}" +CHROME_APP="${CHROME_APP:-/Applications/Google Chrome.app}" +CHROME_BIN="${CHROME_BIN:-}" +CHROME_EXTRA_ARGS="${CHROME_EXTRA_ARGS:-}" +CI_HEADLESS="${CI_HEADLESS:-${CI:-}}" +SERVER_LOG="${SERVER_LOG:-/tmp/agent_studio_smoke_server.log}" +CHROME_LOG="${CHROME_LOG:-/tmp/agent_studio_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}" "${RESULT_FILE}" "${FAILURE_ARTIFACT_FILE}" "${FAILURE_SCREENSHOT_FILE}" "${DESKTOP_SCREENSHOT_FILE}" "${MOBILE_SCREENSHOT_FILE}" "${VISUAL_REVIEW_FILE}" +rm -rf "${CHROME_USER_DIR}" +rm -f "${SERVER_LOG}" "${CHROME_LOG}" + +DATABASE_URL="sqlite:///${SMOKE_DB}" + +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 Agent Studio smoke verification..." +node "${ROOT_DIR}/scripts/verify_agent_studio_smoke.js" \ + --url "${APP_URL}" \ + --database-url "${DATABASE_URL}" \ + --result-file "${RESULT_FILE}" \ + --failure-artifact-file "${FAILURE_ARTIFACT_FILE}" \ + --failure-screenshot-file "${FAILURE_SCREENSHOT_FILE}" \ + --desktop-screenshot-file "${DESKTOP_SCREENSHOT_FILE}" \ + --mobile-screenshot-file "${MOBILE_SCREENSHOT_FILE}" \ + --visual-review-file "${VISUAL_REVIEW_FILE}" \ + --chrome-port "${CHROME_PORT}" + +echo "Agent Studio smoke passed." diff --git a/scripts/run_backend_local.sh b/scripts/run_backend_local.sh new file mode 100755 index 0000000..e44391e --- /dev/null +++ b/scripts/run_backend_local.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +if [[ -f .env.local ]]; then + set -a + source ./.env.local + set +a +fi + +if [[ -f .env ]]; then + set -a + source ./.env + set +a +fi + +PYTHON_BIN="./.venv311/bin/python" +if [[ ! -x "$PYTHON_BIN" ]]; then + PYTHON_BIN="./.venv/bin/python" +fi +if [[ ! -x "$PYTHON_BIN" ]]; then + PYTHON_BIN="$(command -v python3)" +fi + +APP_HOST="${APP_HOST:-127.0.0.1}" +APP_PORT="${APP_PORT:-8000}" + +if curl -sf "http://${APP_HOST}:${APP_PORT}/health" >/dev/null 2>&1; then + echo "app_already_running:http://${APP_HOST}:${APP_PORT}" + exit 0 +fi + +"$PYTHON_BIN" scripts/check_database_env.py --format text + +exec "$PYTHON_BIN" -m uvicorn src.narrativeos.api:app --host "${APP_HOST}" --port "${APP_PORT}" diff --git a/scripts/run_frontend_shell_smoke.sh b/scripts/run_frontend_shell_smoke.sh new file mode 100755 index 0000000..c4bd5ad --- /dev/null +++ b/scripts/run_frontend_shell_smoke.sh @@ -0,0 +1,150 @@ +#!/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/frontend_shell_smoke.db" +RESULT_FILE="${ROOT_DIR}/artifacts/frontend_shell_smoke_result.json" +FAILURE_ARTIFACT_FILE="${ROOT_DIR}/artifacts/frontend_shell_smoke_failure_snapshot.json" +FAILURE_SCREENSHOT_FILE="${ROOT_DIR}/artifacts/frontend_shell_smoke_failure.png" +APP_PORT="${APP_PORT:-8010}" +APP_URL="${APP_URL:-http://127.0.0.1:${APP_PORT}/app?debug=1}" +CHROME_PORT="${CHROME_PORT:-9224}" +CHROME_USER_DIR="${CHROME_USER_DIR:-/tmp/narrativeos-chrome-frontend-shell}" +CHROME_APP="${CHROME_APP:-/Applications/Google Chrome.app}" +CHROME_BIN="${CHROME_BIN:-}" +CHROME_EXTRA_ARGS="${CHROME_EXTRA_ARGS:-}" +CI_HEADLESS="${CI_HEADLESS:-${CI:-}}" +SERVER_LOG="${SERVER_LOG:-/tmp/frontend_shell_smoke_server.log}" +CHROME_LOG="${CHROME_LOG:-/tmp/frontend_shell_smoke_chrome.log}" +SERVER_PID="" +CHROME_PID="" +REUSE_EXISTING_SERVER=0 + +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}" "${RESULT_FILE}" "${FAILURE_ARTIFACT_FILE}" "${FAILURE_SCREENSHOT_FILE}" +rm -rf "${CHROME_USER_DIR}" +rm -f "${SERVER_LOG}" "${CHROME_LOG}" + +DATABASE_URL="sqlite:///${SMOKE_DB}" + +if curl -sf "http://127.0.0.1:${APP_PORT}/health" >/dev/null 2>&1; then + echo "Reusing existing NarrativeOS API on port ${APP_PORT}..." + REUSE_EXISTING_SERVER=1 +else + 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 +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 frontend shell smoke verification..." +node "${ROOT_DIR}/scripts/verify_frontend_shell_smoke.js" \ + --url "${APP_URL}" \ + --database-url "${DATABASE_URL}" \ + --result-file "${RESULT_FILE}" \ + --failure-artifact-file "${FAILURE_ARTIFACT_FILE}" \ + --failure-screenshot-file "${FAILURE_SCREENSHOT_FILE}" \ + --chrome-port "${CHROME_PORT}" + +echo "Frontend shell smoke passed." diff --git a/scripts/run_reader_shell_smoke.sh b/scripts/run_reader_shell_smoke.sh new file mode 100755 index 0000000..dd4d1d1 --- /dev/null +++ b/scripts/run_reader_shell_smoke.sh @@ -0,0 +1,145 @@ +#!/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_shell_smoke.db" +RESULT_FILE="${ROOT_DIR}/artifacts/reader_shell_smoke_result.json" +FAILURE_ARTIFACT_FILE="${ROOT_DIR}/artifacts/reader_shell_smoke_failure_snapshot.json" +FAILURE_SCREENSHOT_FILE="${ROOT_DIR}/artifacts/reader_shell_smoke_failure.png" +APP_PORT="${APP_PORT:-8010}" +APP_URL="${APP_URL:-http://127.0.0.1:${APP_PORT}/app?debug=1}" +CHROME_PORT="${CHROME_PORT:-9224}" +CHROME_USER_DIR="${CHROME_USER_DIR:-/tmp/narrativeos-chrome-reader-shell}" +CHROME_APP="${CHROME_APP:-/Applications/Google Chrome.app}" +CHROME_BIN="${CHROME_BIN:-}" +CHROME_EXTRA_ARGS="${CHROME_EXTRA_ARGS:-}" +CI_HEADLESS="${CI_HEADLESS:-${CI:-}}" +SERVER_LOG="${SERVER_LOG:-/tmp/reader_shell_smoke_server.log}" +CHROME_LOG="${CHROME_LOG:-/tmp/reader_shell_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}" "${RESULT_FILE}" "${FAILURE_ARTIFACT_FILE}" "${FAILURE_SCREENSHOT_FILE}" +rm -rf "${CHROME_USER_DIR}" +rm -f "${SERVER_LOG}" "${CHROME_LOG}" + +DATABASE_URL="sqlite:///${SMOKE_DB}" + +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 shell smoke verification..." +node "${ROOT_DIR}/scripts/verify_frontend_shell_smoke.js" \ + --scope reader \ + --url "${APP_URL}" \ + --database-url "${DATABASE_URL}" \ + --result-file "${RESULT_FILE}" \ + --failure-artifact-file "${FAILURE_ARTIFACT_FILE}" \ + --failure-screenshot-file "${FAILURE_SCREENSHOT_FILE}" \ + --chrome-port "${CHROME_PORT}" + +echo "Reader shell smoke passed." diff --git a/scripts/verify_agent_studio_smoke.js b/scripts/verify_agent_studio_smoke.js new file mode 100644 index 0000000..f387351 --- /dev/null +++ b/scripts/verify_agent_studio_smoke.js @@ -0,0 +1,955 @@ +const fs = require("fs"); +const http = require("http"); +const path = require("path"); + +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 httpJson({ method = "GET", hostname = "127.0.0.1", port, path, body = undefined, headers = {} }) { + return new Promise((resolve, reject) => { + const request = http.request({ method, hostname, port, path, headers }, (response) => { + let data = ""; + response.on("data", (chunk) => { + data += chunk; + }); + response.on("end", () => { + const statusCode = Number(response.statusCode || 0); + try { + const parsed = JSON.parse(data); + if (statusCode >= 400) { + reject(new Error(`HTTP ${statusCode} ${path}: ${typeof parsed === "object" ? JSON.stringify(parsed) : String(parsed)}`)); + return; + } + resolve(parsed); + } catch (_error) { + if (statusCode >= 400) { + reject(new Error(`HTTP ${statusCode} ${path}: ${data}`)); + return; + } + reject(new Error(`Failed to parse JSON from ${path}: ${data}`)); + } + }); + }); + request.on("error", reject); + if (body !== undefined) request.write(body); + 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(), + }); + return; + } + if (message.method === "Log.entryAdded" && message.params?.entry?.level === "error") { + consoleErrors.push({ type: "log.error", text: message.params.entry.text || "" }); + } + }; + + 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"); + await send("Log.enable"); + + return { ws, evaluate, send, 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) { + const ready = await evaluate(`Boolean(${expression})`); + if (ready) return; + await sleep(250); + } + throw new Error(`Timed out waiting for ${label}`); +} + +async function clickSelector(evaluate, selector) { + const escaped = JSON.stringify(selector); + return evaluate(`(() => { + const el = document.querySelector(${escaped}); + if (!el) throw new Error('Missing selector: ' + ${escaped}); + el.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 setChecked(evaluate, selector, checked) { + const escapedSelector = JSON.stringify(selector); + return evaluate(`(() => { + const el = document.querySelector(${escapedSelector}); + if (!el) throw new Error('Missing selector: ' + ${escapedSelector}); + el.checked = ${checked ? "true" : "false"}; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + return el.checked; + })()`); +} + +async function setViewport(send, { width, height, mobile = false }) { + await send("Emulation.setDeviceMetricsOverride", { + width, + height, + deviceScaleFactor: 1, + mobile, + screenWidth: width, + screenHeight: height, + }); +} + +async function captureScreenshotToFile(send, screenshotFile) { + const result = await send("Page.captureScreenshot", { + format: "png", + fromSurface: true, + }); + fs.mkdirSync(path.dirname(screenshotFile), { recursive: true }); + fs.writeFileSync(screenshotFile, Buffer.from(result.data, "base64")); + return { + screenshot_file: screenshotFile, + screenshot_bytes: Buffer.byteLength(result.data, "base64"), + }; +} + +async function collectStudioQaSnapshot(evaluate) { + return evaluate(`(() => { + const text = document.querySelector('#agent-studio-shell')?.innerText || ''; + const visible = (selector) => { + const el = document.querySelector(selector); + if (!el) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; + }; + const scrollWidth = Math.max( + document.documentElement?.scrollWidth || 0, + document.body?.scrollWidth || 0 + ); + const viewportWidth = window.innerWidth || document.documentElement?.clientWidth || 0; + return { + reader_body_length: (document.querySelector('#agent-studio-reader-body')?.innerText || '').trim().length, + director_visible: visible('.agent-studio-director'), + branch_map_visible: visible('#agent-studio-branches'), + quality_visible: visible('#agent-studio-quality'), + quality_labels: Array.from(document.querySelectorAll('#agent-studio-quality .agent-studio-quality-item span')).map((item) => item.innerText.trim()), + workbench_visible: visible('#agent-studio-workbench'), + visible_q_code: /Q03|Q04|Q05|Q09/.test(text), + empty_shell: text.trim().length < 200, + horizontal_overflow_width: Math.max(0, scrollWidth - viewportWidth), + viewport_width: viewportWidth, + viewport_height: window.innerHeight || document.documentElement?.clientHeight || 0, + }; + })()`); +} + +async function collectDesktopStickyDirectorSnapshot(evaluate) { + await evaluate(`(() => { + const target = document.querySelector('.agent-studio-choice-section') || document.querySelector('.agent-studio-rail'); + target?.scrollIntoView({ block: 'start' }); + return true; + })()`); + await sleep(250); + return evaluate(`(() => { + const director = document.querySelector('.agent-studio-director'); + const style = director ? window.getComputedStyle(director) : null; + const rect = director ? director.getBoundingClientRect() : null; + const visibleInViewport = Boolean( + rect && + rect.width > 0 && + rect.height > 0 && + rect.bottom > 0 && + rect.top < window.innerHeight && + rect.right > 0 && + rect.left < window.innerWidth + ); + const directorRectTop = rect ? Number(rect.top) : null; + const stickyDirector = + Number(window.scrollY || 0) > 0 && + style?.position === 'sticky' && + visibleInViewport && + Number.isFinite(directorRectTop) && + directorRectTop >= 0 && + directorRectTop <= 32; + return { + scroll_y: Number(window.scrollY || 0), + viewport_height: Number(window.innerHeight || 0), + director_position: style?.position || '', + director_top_style: style?.top || '', + director_rect_top: directorRectTop, + director_rect_bottom: rect ? Number(rect.bottom) : null, + director_visible_in_viewport: visibleInViewport, + desktop_sticky_director: stickyDirector, + }; + })()`); +} + +async function collectMobileChoiceScrollSnapshot(evaluate) { + return evaluate(`(() => { + const choiceGrid = document.querySelector('.agent-studio-choice-grid'); + const director = document.querySelector('.agent-studio-director'); + const choiceStyle = choiceGrid ? window.getComputedStyle(choiceGrid) : null; + const choiceRect = choiceGrid ? choiceGrid.getBoundingClientRect() : null; + const directorRect = director ? director.getBoundingClientRect() : null; + const viewportHeight = Number(window.innerHeight || 0); + const maxAllowedHeight = Math.min(380, viewportHeight * 0.45); + const clientHeight = Number(choiceGrid?.clientHeight || 0); + const scrollHeight = Number(choiceGrid?.scrollHeight || 0); + const overflowY = choiceStyle?.overflowY || ''; + const boundedScroll = + Boolean(choiceGrid) && + clientHeight > 0 && + clientHeight <= maxAllowedHeight + 2 && + ['auto', 'scroll'].includes(overflowY) && + (choiceStyle?.maxHeight || '') !== 'none'; + return { + viewport_height: viewportHeight, + mobile_choice_client_height: clientHeight, + mobile_choice_scroll_height: scrollHeight, + mobile_choice_overflow_y: overflowY, + mobile_choice_max_height: choiceStyle?.maxHeight || '', + mobile_choice_max_allowed_height: maxAllowedHeight, + mobile_choice_rect_top: choiceRect ? Number(choiceRect.top) : null, + mobile_choice_rect_bottom: choiceRect ? Number(choiceRect.bottom) : null, + mobile_director_rect_top: directorRect ? Number(directorRect.top) : null, + mobile_choice_bounded_scroll: boundedScroll, + }; + })()`); +} + +async function readGenerationWaitCopy(evaluate) { + return evaluate(`(() => { + const status = authorState.agentStudio?.generationStatus || {}; + return { + kind: status.kind || '', + message: status.message || '', + detail: status.detail || '', + startedAt: status.startedAt || '', + visible_text: document.querySelector('#agent-studio-generation-status')?.innerText || '' + }; + })()`); +} + +function markdownCell(value) { + return String(value ?? "") + .replaceAll("|", "\\|") + .replaceAll("\r", " ") + .replaceAll("\n", " "); +} + +function checklistRow(viewport, check, passed, evidence, reviewerNote = "") { + return { + viewport, + check, + status: passed ? "auto_pass" : "blocking_failure", + evidence, + reviewer_note: reviewerNote, + }; +} + +function manualReviewRow(viewport, check, evidence, reviewerNote) { + return { + viewport, + check, + status: "manual_review", + evidence, + reviewer_note: reviewerNote, + }; +} + +function buildVisualReviewChecklist({ + desktopQaSnapshot, + mobileQaSnapshot, + desktopScreenshot, + mobileScreenshot, + exportSnapshot, + expectedQualityLabels, + desktopStickyDirectorSnapshot, + mobileChoiceScrollSnapshot, +}) { + const desktopQualityLabelsPresent = expectedQualityLabels.every((label) => desktopQaSnapshot.quality_labels.includes(label)); + const mobileQualityLabelsPresent = expectedQualityLabels.every((label) => mobileQaSnapshot.quality_labels.includes(label)); + return [ + checklistRow("desktop", "Reader body present", desktopQaSnapshot.reader_body_length >= 80, `${desktopQaSnapshot.reader_body_length} chars`), + checklistRow("desktop", "Director panel visible", desktopQaSnapshot.director_visible, String(desktopQaSnapshot.director_visible)), + checklistRow( + "desktop", + "Desktop sticky director", + Boolean(desktopStickyDirectorSnapshot?.desktop_sticky_director), + `position=${desktopStickyDirectorSnapshot?.director_position || "-"}, top=${desktopStickyDirectorSnapshot?.director_rect_top ?? "-"}, scrollY=${desktopStickyDirectorSnapshot?.scroll_y ?? "-"}` + ), + checklistRow("desktop", "Branch map visible", desktopQaSnapshot.branch_map_visible, String(desktopQaSnapshot.branch_map_visible)), + checklistRow("desktop", "Quality labels visible", desktopQualityLabelsPresent, desktopQaSnapshot.quality_labels.join(", ")), + manualReviewRow( + "desktop", + "Three-column workbench review", + desktopScreenshot.screenshot_file, + "Confirm the layout feels balanced, content does not overlap, and the reader remains the primary focus." + ), + checklistRow("mobile", "No horizontal overflow", mobileQaSnapshot.horizontal_overflow_width <= 2, `${mobileQaSnapshot.horizontal_overflow_width}px overflow`), + checklistRow("mobile", "Reader body present", mobileQaSnapshot.reader_body_length >= 80, `${mobileQaSnapshot.reader_body_length} chars`), + checklistRow("mobile", "Director panel visible", mobileQaSnapshot.director_visible, String(mobileQaSnapshot.director_visible)), + checklistRow( + "mobile", + "Mobile choice bounded scroll", + Boolean(mobileChoiceScrollSnapshot?.mobile_choice_bounded_scroll), + `client=${mobileChoiceScrollSnapshot?.mobile_choice_client_height ?? "-"}, scroll=${mobileChoiceScrollSnapshot?.mobile_choice_scroll_height ?? "-"}, overflowY=${mobileChoiceScrollSnapshot?.mobile_choice_overflow_y || "-"}` + ), + checklistRow("mobile", "Branch map visible", mobileQaSnapshot.branch_map_visible, String(mobileQaSnapshot.branch_map_visible)), + checklistRow("mobile", "Quality labels visible", mobileQualityLabelsPresent, mobileQaSnapshot.quality_labels.join(", ")), + manualReviewRow( + "mobile", + "Stacked workbench review", + mobileScreenshot.screenshot_file, + "Confirm the stacked layout is readable, controls are reachable, and no text is clipped." + ), + checklistRow( + "shared", + "No visible quality codes", + !desktopQaSnapshot.visible_q_code && !mobileQaSnapshot.visible_q_code && !exportSnapshot.visible_q_code, + `desktop=${desktopQaSnapshot.visible_q_code}, mobile=${mobileQaSnapshot.visible_q_code}, export=${exportSnapshot.visible_q_code}` + ), + checklistRow( + "shared", + "No empty shell", + !desktopQaSnapshot.empty_shell && !mobileQaSnapshot.empty_shell, + `desktop=${desktopQaSnapshot.empty_shell}, mobile=${mobileQaSnapshot.empty_shell}` + ), + checklistRow( + "shared", + "Screenshots captured", + Number(desktopScreenshot.screenshot_bytes || 0) > 0 && Number(mobileScreenshot.screenshot_bytes || 0) > 0, + `desktop=${desktopScreenshot.screenshot_bytes || 0} bytes, mobile=${mobileScreenshot.screenshot_bytes || 0} bytes` + ), + ]; +} + +function summarizeVisualReview(checklist) { + return { + visual_review_total: checklist.length, + visual_review_auto_pass: checklist.filter((item) => item.status === "auto_pass").length, + visual_review_manual_review: checklist.filter((item) => item.status === "manual_review").length, + visual_review_blocking_failures: checklist.filter((item) => item.status === "blocking_failure").length, + }; +} + +function writeVisualReviewMarkdown(visualReviewFile, checklist, summary, { desktopScreenshotFile, mobileScreenshotFile }) { + const lines = [ + "# Agent Studio Visual Review", + "", + `- Desktop screenshot: \`${desktopScreenshotFile}\``, + `- Mobile screenshot: \`${mobileScreenshotFile}\``, + `- Auto pass: \`${summary.visual_review_auto_pass}\``, + `- Manual review: \`${summary.visual_review_manual_review}\``, + `- Blocking failures: \`${summary.visual_review_blocking_failures}\``, + "", + "| Viewport | Check | Status | Evidence | Reviewer note |", + "| --- | --- | --- | --- | --- |", + ]; + for (const item of checklist) { + lines.push( + `| ${markdownCell(item.viewport)} | ${markdownCell(item.check)} | ${markdownCell(item.status)} | ${markdownCell(item.evidence)} | ${markdownCell(item.reviewer_note)} |` + ); + } + lines.push(""); + fs.mkdirSync(path.dirname(visualReviewFile), { recursive: true }); + fs.writeFileSync(visualReviewFile, lines.join("\n"), "utf8"); +} + +async function buildAuthHeaders(hostname, port, actorId, actorRole, password) { + await httpJson({ + method: "POST", + hostname, + port, + path: "/v1/auth/register", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + actor_id: actorId, + actor_role: actorRole, + password, + account_id: actorId, + }), + }); + const login = await httpJson({ + method: "POST", + hostname, + port, + path: "/v1/auth/login", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ actor_id: actorId, password }), + }); + return { Authorization: `Bearer ${login.token.access_token}`, "Content-Type": "application/json" }; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const url = args.url; + const appUrl = new URL(url); + const chromePort = Number(args["chrome-port"] || 9238); + const databaseUrl = args["database-url"]; + const resultFile = args["result-file"]; + const failureArtifactFile = args["failure-artifact-file"]; + const failureScreenshotFile = args["failure-screenshot-file"]; + const desktopScreenshotFile = args["desktop-screenshot-file"]; + const mobileScreenshotFile = args["mobile-screenshot-file"]; + const visualReviewFile = args["visual-review-file"]; + const GUARD = { id: "agent_studio_smoke", label: "Agent Studio Smoke" }; + if (!url || !databaseUrl || !resultFile || !failureArtifactFile || !failureScreenshotFile || !desktopScreenshotFile || !mobileScreenshotFile || !visualReviewFile) { + throw new Error("Usage: node verify_agent_studio_smoke.js --url --database-url --result-file --failure-artifact-file --failure-screenshot-file --desktop-screenshot-file --mobile-screenshot-file --visual-review-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 writeFailureScreenshot = (base64Png) => { + fs.mkdirSync(path.dirname(failureScreenshotFile), { recursive: true }); + fs.writeFileSync(failureScreenshotFile, Buffer.from(base64Png, "base64")); + }; + + const stepOrder = []; + let currentStep = "bootstrap"; + const markStep = (step) => { + currentStep = step; + }; + const completeStep = (step) => { + stepOrder.push(step); + }; + const buildResultPayload = ({ status, failedStep = null, summary = {}, consoleErrors = [], errorMessage = null, failureArtifact = null, failureScreenshot = null, visualReviewChecklist = [] }) => ({ + status, + schema_version: "agent_studio_smoke/v1", + guard: GUARD, + summary_meta: { + primary_key: "completed_steps", + primary_count: stepOrder.length, + summary_key_count: Object.keys(summary || {}).length, + }, + artifacts: { + result_file: resultFile, + failure_artifact_file: failureArtifact, + failure_screenshot_file: failureScreenshot, + desktop_screenshot_file: desktopScreenshotFile, + mobile_screenshot_file: mobileScreenshotFile, + visual_review_file: visualReviewFile, + }, + app_url: url, + completed_steps: stepOrder, + failed_step: failedStep, + console_errors: consoleErrors, + ...(errorMessage ? { error_message: errorMessage } : {}), + visual_review_checklist: visualReviewChecklist, + summary, + failure_artifact_file: failureArtifact, + failure_screenshot_file: failureScreenshot, + }); + + await openAppTarget(chromePort, url); + await sleep(1000); + const { ws, evaluate, send, consoleErrors } = await connectToPage(url, chromePort); + + const captureFailureSnapshot = async () => { + try { + return await evaluate(`({ + title: document.title || "", + url: location.href || "", + product: document.querySelector('#app-shell')?.dataset.product || "", + workspace: new URL(location.href).searchParams.get('workspace') || "", + studio_text: document.querySelector('#agent-studio-shell')?.innerText?.slice(0, 3000) || "", + active_work_id: typeof authorState !== 'undefined' ? authorState.activeWorkId || "" : "", + chapter_count: typeof authorState !== 'undefined' ? Number(authorState.activeWorkDetail?.chapter_count || 0) : 0, + route_count: typeof authorState !== 'undefined' ? Number(authorState.activeWorkDetail?.branch_family?.length || 0) : 0, + generation_status: document.querySelector('#agent-studio-generation-status')?.innerText || "", + body_text_excerpt: (document.body?.innerText || "").slice(0, 4000) + })`); + } catch (error) { + return { snapshot_error: error && error.message ? error.message : String(error) }; + } + }; + + const captureFailureScreenshot = async () => { + try { + const result = await send("Page.captureScreenshot", { + format: "png", + fromSurface: true, + }); + writeFailureScreenshot(result.data); + return { screenshot_file: failureScreenshotFile, screenshot_error: null }; + } catch (error) { + return { screenshot_file: null, screenshot_error: error && error.message ? error.message : String(error) }; + } + }; + + try { + await setViewport(send, { width: 1440, height: 1000 }); + const authorActorId = `agent_studio_${Date.now()}`; + const authorPassword = "secret123"; + const reviewerHeaders = await buildAuthHeaders(appUrl.hostname, Number(appUrl.port || 80), `agent_studio_reviewer_${Date.now()}`, "reviewer", authorPassword); + + markStep("load_page_title"); + await waitFor(evaluate, "page title", `document.title === 'NarrativeOS Studio'`); + completeStep("load_page_title"); + + markStep("wait_for_agent_studio_bootstrap"); + await waitFor( + evaluate, + "Agent Studio bootstrap", + `window.__bootMarker === 'after-init' + && document.querySelector('#app-shell')?.dataset.product === 'author' + && new URL(location.href).searchParams.get('workspace') === 'studio' + && typeof AuthorWorkspaceRuntime === 'object' + && typeof AgentStudioRuntime === 'object' + && document.querySelector('#agent-studio-shell') + && !document.querySelector('#agent-studio-shell')?.innerText.includes('Q03')`, + 30000 + ); + completeStep("wait_for_agent_studio_bootstrap"); + + markStep("author_register_login"); + await setValue(evaluate, "#author-auth-actor-id", authorActorId); + await setValue(evaluate, "#author-auth-display-name", "Agent Studio Smoke"); + await setValue(evaluate, "#author-auth-password", authorPassword); + await setValue(evaluate, "#author-auth-role", "author"); + await clickSelector(evaluate, "#author-auth-register"); + await waitFor( + evaluate, + "author session established", + `Boolean(authorState.authorAuthSession?.accessToken) + && (document.querySelector('#author-account-id')?.value || '') === ${JSON.stringify(authorActorId)}`, + 30000 + ); + completeStep("author_register_login"); + + markStep("grant_author_creator_access"); + await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: "/v1/ops/subscriptions/grant", + headers: reviewerHeaders, + body: JSON.stringify({ + account_id: authorActorId, + tier_id: "creator_pass", + provider: "ops_manual", + status: "active", + }), + }); + await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: "/v1/ops/wallets/grant", + headers: reviewerHeaders, + body: JSON.stringify({ + account_id: authorActorId, + wallet_type: "studio_credits", + amount: 10, + }), + }); + await evaluate(`(async () => { + await AuthorWorkspaceRuntime.refreshAuthorSurface(); + WorkspaceLayoutRuntime.setAuthorWorkspace('studio'); + ShellStatusRuntime.syncProductMode(); + AgentStudioRuntime.refreshStudio(); + return true; + })()`); + await waitFor( + evaluate, + "author credits and Studio visible", + `Number(document.querySelector('#author-studio-credits')?.innerText || 0) >= 10 + && new URL(location.href).searchParams.get('workspace') === 'studio' + && document.querySelector('#agent-studio-start')`, + 30000 + ); + completeStep("grant_author_creator_access"); + + markStep("agent_studio_startup"); + const storyTitle = `雾港协作 ${Date.now()}`; + await setValue(evaluate, "#agent-studio-title", storyTitle); + await setValue(evaluate, "#agent-studio-genre", "urban_mystery"); + await setValue(evaluate, "#agent-studio-length", "short"); + await setValue(evaluate, "#agent-studio-reader-goal", "慢热悬疑"); + await setChecked(evaluate, "#agent-studio-remix", true); + await clickSelector(evaluate, "#agent-studio-start-button"); + await waitFor( + evaluate, + "Studio startup wait copy", + `authorState.agentStudio?.generationStatus?.message === '第一章生成中' + && authorState.agentStudio?.generationStatus?.detail === '正在建立作品设定、人物冲突和章节正文,可能需要一两分钟。' + && (document.querySelector('#agent-studio-generation-status')?.innerText || '').includes('第一章生成中')`, + 5000 + ); + const startupWaitCopy = await readGenerationWaitCopy(evaluate); + await waitFor( + evaluate, + "first Studio chapter", + `Boolean(authorState.activeWorkId) + && Number(authorState.activeWorkDetail?.chapter_count || 0) >= 1 + && !document.querySelector('#agent-studio-workbench')?.classList.contains('is-hidden') + && (document.querySelector('#agent-studio-reader-body')?.innerText || '').length > 80`, + 180000 + ); + const startupSnapshot = await evaluate(`({ + work_id: authorState.activeWorkId || '', + chapter_count: Number(authorState.activeWorkDetail?.chapter_count || 0), + route_count: Number(authorState.activeWorkDetail?.branch_family?.length || 0), + visible_q_code: /Q03|Q04|Q05|Q09/.test(document.querySelector('#agent-studio-shell')?.innerText || ''), + reader_title: document.querySelector('#agent-studio-reader-title')?.innerText || '' + })`); + if (startupSnapshot.visible_q_code) { + throw new Error("Agent Studio exposed internal quality code in visible copy."); + } + const desktopQaSnapshot = await collectStudioQaSnapshot(evaluate); + const expectedQualityLabels = ["重复感", "场景细节", "节奏", "结尾风险"]; + if (desktopQaSnapshot.reader_body_length < 80) { + throw new Error("Agent Studio desktop reader body was blank or too short."); + } + if (!desktopQaSnapshot.director_visible || !desktopQaSnapshot.branch_map_visible || !desktopQaSnapshot.quality_visible) { + throw new Error("Agent Studio desktop workbench is missing director, branch map, or quality status."); + } + if (!expectedQualityLabels.every((label) => desktopQaSnapshot.quality_labels.includes(label))) { + throw new Error(`Agent Studio quality labels missing in desktop viewport: ${desktopQaSnapshot.quality_labels.join(",")}`); + } + if (desktopQaSnapshot.visible_q_code || desktopQaSnapshot.empty_shell) { + throw new Error("Agent Studio desktop screenshot target exposed Q-code or rendered an empty shell."); + } + await setViewport(send, { width: 1440, height: 1000 }); + await evaluate(`document.querySelector('#agent-studio-workbench')?.scrollIntoView({ block: 'start' }); true`); + await sleep(250); + const desktopScreenshot = await captureScreenshotToFile(send, desktopScreenshotFile); + completeStep("agent_studio_startup"); + + markStep("agent_studio_sticky_director_regression"); + const desktopStickyDirectorSnapshot = await collectDesktopStickyDirectorSnapshot(evaluate); + if (!desktopStickyDirectorSnapshot.desktop_sticky_director) { + throw new Error(`Agent Studio sticky director regression: ${JSON.stringify(desktopStickyDirectorSnapshot)}`); + } + await evaluate(`document.querySelector('#agent-studio-workbench')?.scrollIntoView({ block: 'start' }); true`); + await sleep(250); + completeStep("agent_studio_sticky_director_check"); + + markStep("agent_studio_director_continue"); + await setValue(evaluate, "#agent-studio-director-intent", "增加感情张力,但不要揭晓真相。"); + await clickSelector(evaluate, "#agent-studio-generate"); + await waitFor( + evaluate, + "Studio continuation wait copy", + `authorState.agentStudio?.generationStatus?.message === '续写中' + && authorState.agentStudio?.generationStatus?.detail === '正在沿导演意图推进下一章,完成后会自动跳到新章节。' + && (document.querySelector('#agent-studio-generation-status')?.innerText || '').includes('续写中')`, + 5000 + ); + const continuationWaitCopy = await readGenerationWaitCopy(evaluate); + await waitFor( + evaluate, + "Studio director continuation", + `Number(authorState.activeWorkDetail?.chapter_count || 0) >= 2 + && (document.querySelector('#agent-studio-generation-status')?.innerText || '').includes('章已完成') + && (document.querySelector('#agent-studio-reader-body')?.innerText || '').length > 80`, + 180000 + ); + const continueSnapshot = await evaluate(`({ + work_id: authorState.activeWorkId || '', + chapter_count: Number(authorState.activeWorkDetail?.chapter_count || 0), + route_count: Number(authorState.activeWorkDetail?.branch_family?.length || 0), + choice_card_count: document.querySelectorAll('#agent-studio-choice-cards .agent-studio-choice-card').length, + generation_status: document.querySelector('#agent-studio-generation-status')?.innerText || '' + })`); + completeStep("agent_studio_director_continue"); + + markStep("agent_studio_create_branch"); + const routeCountBeforeBranch = Number(continueSnapshot.route_count || 0); + await setValue(evaluate, "#agent-studio-director-intent", "从这里开启一条路线:主角提前摊牌,但继续隐藏真正证据。"); + await clickSelector(evaluate, "#agent-studio-create-branch"); + await waitFor( + evaluate, + "Studio branch wait copy", + `authorState.agentStudio?.generationStatus?.message === '新路线创建中' + && authorState.agentStudio?.generationStatus?.detail === '正在从当前章节保存分支。' + && (document.querySelector('#agent-studio-generation-status')?.innerText || '').includes('新路线创建中')`, + 5000 + ); + const branchWaitCopy = await readGenerationWaitCopy(evaluate); + await waitFor( + evaluate, + "Studio branch created", + `Number(authorState.activeWorkDetail?.branch_family?.length || 0) > ${routeCountBeforeBranch} + && (document.querySelector('#agent-studio-generation-status')?.innerText || '').includes('新路线已创建') + && document.querySelectorAll('#agent-studio-branches .agent-studio-route-card').length > ${routeCountBeforeBranch}`, + 60000 + ); + const branchSnapshot = await evaluate(`({ + work_id: authorState.activeWorkId || '', + chapter_count: Number(authorState.activeWorkDetail?.chapter_count || 0), + route_count: Number(authorState.activeWorkDetail?.branch_family?.length || 0), + branch_text: document.querySelector('#agent-studio-branches')?.innerText || '', + active_route_label: document.querySelector('#agent-studio-route-select option:checked')?.innerText || '' + })`); + completeStep("agent_studio_create_branch"); + + markStep("agent_studio_export_nosbook"); + await clickSelector(evaluate, "#agent-studio-export"); + await waitFor( + evaluate, + "Studio nosbook export", + `authorState.agentStudio?.lastNosbookExport?.payload?.schema_version === 'nosbook/v1' + && authorState.agentStudio?.lastNosbookExport?.contentType === 'application/vnd.narrativeos.nosbook+json' + && Array.isArray(authorState.agentStudio?.lastNosbookExport?.payload?.chapters) + && authorState.agentStudio.lastNosbookExport.payload.chapters.length >= 1 + && Array.isArray(authorState.agentStudio.lastNosbookExport.payload.branch_map) + && authorState.agentStudio.lastNosbookExport.payload.branch_map.length >= 2`, + 30000 + ); + const exportSnapshot = await evaluate(`(() => { + const exportPayload = authorState.agentStudio.lastNosbookExport.payload; + return { + filename: authorState.agentStudio.lastNosbookExport.filename || '', + content_type: authorState.agentStudio.lastNosbookExport.contentType || '', + schema_version: exportPayload.schema_version || '', + chapter_count: Array.isArray(exportPayload.chapters) ? exportPayload.chapters.length : 0, + branch_map_count: Array.isArray(exportPayload.branch_map) ? exportPayload.branch_map.length : 0, + choice_history_count: Array.isArray(exportPayload.choice_history) ? exportPayload.choice_history.length : 0, + quality_summary_keys: Object.keys(exportPayload.quality_summary || {}), + export_route_name: exportPayload.export_route?.route_name || '', + visible_q_code: /Q03|Q04|Q05|Q09/.test(document.querySelector('#agent-studio-shell')?.innerText || '') + }; + })()`); + if (exportSnapshot.visible_q_code) { + throw new Error("Agent Studio exposed internal quality code after export."); + } + if (exportSnapshot.choice_history_count < 1) { + throw new Error("Agent Studio export did not include choice history."); + } + await setViewport(send, { width: 390, height: 844, mobile: true }); + await evaluate(`document.querySelector('#agent-studio-workbench')?.scrollIntoView({ block: 'start' }); true`); + await sleep(500); + const mobileQaSnapshot = await collectStudioQaSnapshot(evaluate); + if (mobileQaSnapshot.reader_body_length < 80) { + throw new Error("Agent Studio mobile reader body was blank or too short."); + } + if (!mobileQaSnapshot.director_visible || !mobileQaSnapshot.branch_map_visible || !mobileQaSnapshot.quality_visible) { + throw new Error("Agent Studio mobile workbench is missing director, branch map, or quality status."); + } + if (!expectedQualityLabels.every((label) => mobileQaSnapshot.quality_labels.includes(label))) { + throw new Error(`Agent Studio quality labels missing in mobile viewport: ${mobileQaSnapshot.quality_labels.join(",")}`); + } + if (mobileQaSnapshot.visible_q_code || mobileQaSnapshot.empty_shell) { + throw new Error("Agent Studio mobile screenshot target exposed Q-code or rendered an empty shell."); + } + if (mobileQaSnapshot.horizontal_overflow_width > 2) { + throw new Error(`Agent Studio mobile viewport has horizontal overflow: ${mobileQaSnapshot.horizontal_overflow_width}px`); + } + + markStep("agent_studio_mobile_choice_scroll_regression"); + const mobileChoiceScrollSnapshot = await collectMobileChoiceScrollSnapshot(evaluate); + if (!mobileChoiceScrollSnapshot.mobile_choice_bounded_scroll) { + throw new Error(`Agent Studio mobile choice scroll regression: ${JSON.stringify(mobileChoiceScrollSnapshot)}`); + } + completeStep("agent_studio_mobile_choice_scroll_check"); + markStep("agent_studio_export_nosbook"); + + const mobileScreenshot = await captureScreenshotToFile(send, mobileScreenshotFile); + await setViewport(send, { width: 1440, height: 1000 }); + const visualReviewChecklist = buildVisualReviewChecklist({ + desktopQaSnapshot, + mobileQaSnapshot, + desktopScreenshot, + mobileScreenshot, + exportSnapshot, + expectedQualityLabels, + desktopStickyDirectorSnapshot, + mobileChoiceScrollSnapshot, + }); + const visualReviewSummary = summarizeVisualReview(visualReviewChecklist); + writeVisualReviewMarkdown(visualReviewFile, visualReviewChecklist, visualReviewSummary, { + desktopScreenshotFile: desktopScreenshot.screenshot_file, + mobileScreenshotFile: mobileScreenshot.screenshot_file, + }); + if (visualReviewSummary.visual_review_blocking_failures > 0) { + throw new Error(`Agent Studio visual review objective checks failed: ${visualReviewSummary.visual_review_blocking_failures}`); + } + completeStep("agent_studio_export_nosbook"); + + const resultPayload = buildResultPayload({ + status: "ok", + consoleErrors, + visualReviewChecklist, + summary: { + headline_metric: "agent_studio_chapters_exported", + headline_value: exportSnapshot.chapter_count, + suite_scope: "agent_studio_smoke", + author_actor_id: authorActorId, + work_id: exportSnapshot.work_id || branchSnapshot.work_id || continueSnapshot.work_id || startupSnapshot.work_id, + startup_chapter_count: startupSnapshot.chapter_count, + chapter_count_after_continue: continueSnapshot.chapter_count, + route_count_after_branch: branchSnapshot.route_count, + nosbook_schema_version: exportSnapshot.schema_version, + nosbook_content_type: exportSnapshot.content_type, + nosbook_chapter_count: exportSnapshot.chapter_count, + nosbook_branch_map_count: exportSnapshot.branch_map_count, + nosbook_choice_history_count: exportSnapshot.choice_history_count, + nosbook_quality_summary_keys: exportSnapshot.quality_summary_keys, + visible_q_code: exportSnapshot.visible_q_code, + desktop_screenshot_file: desktopScreenshot.screenshot_file, + mobile_screenshot_file: mobileScreenshot.screenshot_file, + mobile_overflow_width: mobileQaSnapshot.horizontal_overflow_width, + desktop_sticky_director: desktopStickyDirectorSnapshot.desktop_sticky_director, + desktop_director_top_after_scroll: desktopStickyDirectorSnapshot.director_rect_top, + mobile_choice_bounded_scroll: mobileChoiceScrollSnapshot.mobile_choice_bounded_scroll, + mobile_choice_client_height: mobileChoiceScrollSnapshot.mobile_choice_client_height, + mobile_choice_scroll_height: mobileChoiceScrollSnapshot.mobile_choice_scroll_height, + mobile_choice_overflow_y: mobileChoiceScrollSnapshot.mobile_choice_overflow_y, + generation_wait_copy: { + startup: startupWaitCopy, + continuation: continuationWaitCopy, + branch: branchWaitCopy, + }, + desktop_reader_body_length: desktopQaSnapshot.reader_body_length, + mobile_reader_body_length: mobileQaSnapshot.reader_body_length, + mobile_director_visible: mobileQaSnapshot.director_visible, + mobile_branch_map_visible: mobileQaSnapshot.branch_map_visible, + mobile_quality_labels: mobileQaSnapshot.quality_labels, + visual_review_file: visualReviewFile, + visual_review_total: visualReviewSummary.visual_review_total, + visual_review_auto_pass: visualReviewSummary.visual_review_auto_pass, + visual_review_manual_review: visualReviewSummary.visual_review_manual_review, + visual_review_blocking_failures: visualReviewSummary.visual_review_blocking_failures, + }, + }); + writeResult(resultPayload); + console.log(JSON.stringify(resultPayload, null, 2)); + } catch (error) { + const failureSnapshot = await captureFailureSnapshot(); + const failureScreenshot = await captureFailureScreenshot(); + const failureArtifact = { + status: "error", + app_url: url, + completed_steps: stepOrder, + failed_step: currentStep, + error_message: error && error.message ? error.message : String(error), + console_errors: consoleErrors, + snapshot: failureSnapshot, + screenshot: failureScreenshot, + }; + writeFailureArtifact(failureArtifact); + const resultPayload = buildResultPayload({ + status: "error", + failedStep: currentStep, + errorMessage: error && error.message ? error.message : String(error), + consoleErrors, + summary: { + headline_metric: "completed_steps", + headline_value: stepOrder.length, + suite_scope: "agent_studio_smoke", + }, + failureArtifact: failureArtifactFile, + failureScreenshot: failureScreenshot.screenshot_file, + }); + writeResult(resultPayload); + throw error; + } finally { + ws.close(); + } +} + +main().catch((error) => { + console.error("AGENT_STUDIO_SMOKE_ERROR"); + console.error(error && error.stack ? error.stack : error); + process.exit(1); +}); diff --git a/scripts/verify_frontend_shell_smoke.js b/scripts/verify_frontend_shell_smoke.js new file mode 100644 index 0000000..4d938cb --- /dev/null +++ b/scripts/verify_frontend_shell_smoke.js @@ -0,0 +1,2036 @@ +const fs = require("fs"); +const http = require("http"); +const path = require("path"); +const childProcess = require("child_process"); + +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 parseHttpErrorSummary(message) { + const raw = String(message || ""); + const match = raw.match(/^HTTP\s+(\d+)\s+(\S+):\s+(.+)$/); + if (!match) { + return { + status: null, + endpoint: "", + code: raw, + }; + } + const [, statusText, endpoint, payloadText] = match; + let payload = null; + try { + payload = JSON.parse(payloadText); + } catch (_error) { + payload = payloadText; + } + let code = ""; + if (payload && typeof payload === "object") { + if (typeof payload.code === "string" && payload.code.trim()) { + code = payload.code.trim(); + } else if (typeof payload.detail === "string" && payload.detail.trim()) { + code = payload.detail.trim(); + } else if (typeof payload.reason === "string" && payload.reason.trim()) { + code = payload.reason.trim(); + } else { + code = JSON.stringify(payload); + } + } else { + code = String(payload || "").trim(); + } + return { + status: Number(statusText) || null, + endpoint: String(endpoint || "").trim(), + code, + }; +} + +function httpJson({ method = "GET", hostname = "127.0.0.1", port, path, body = undefined, headers = {} }) { + return new Promise((resolve, reject) => { + const request = http.request({ method, hostname, port, path, headers }, (response) => { + let data = ""; + response.on("data", (chunk) => { + data += chunk; + }); + response.on("end", () => { + const statusCode = Number(response.statusCode || 0); + try { + const parsed = JSON.parse(data); + if (statusCode >= 400) { + reject(new Error(`HTTP ${statusCode} ${path}: ${typeof parsed === "object" ? JSON.stringify(parsed) : String(parsed)}`)); + return; + } + resolve(parsed); + } catch (_error) { + if (statusCode >= 400) { + reject(new Error(`HTTP ${statusCode} ${path}: ${data}`)); + return; + } + reject(new Error(`Failed to parse JSON from ${path}: ${data}`)); + } + }); + }); + request.on("error", reject); + if (body !== undefined) { + request.write(body); + } + 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", + url: message.params?.exceptionDetails?.url || "", + lineNumber: message.params?.exceptionDetails?.lineNumber ?? null, + columnNumber: message.params?.exceptionDetails?.columnNumber ?? null, + }); + 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(), + }); + return; + } + if (message.method === "Log.entryAdded" && message.params?.entry?.level === "error") { + consoleErrors.push({ + type: "log.error", + text: message.params.entry.text || "", + }); + } + }; + + 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"); + await send("Log.enable"); + + return { ws, evaluate, send, 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}`); +} + +async function waitForReaderStepComplete(evaluate, timeoutMs = 65000) { + const start = Date.now(); + let latestSnapshot = {}; + while (Date.now() - start < timeoutMs) { + latestSnapshot = await evaluate(`(() => { + if (typeof readerState === 'undefined') return { ready: false, reason: 'reader_state_missing' }; + const latestOk = Boolean( + readerState.latestStep + && readerState.latestStep.chosen_event + && readerState.currentState + && Number(readerState.currentState.turn_index || 0) >= 1 + ); + const qualityGuardHandled = Boolean( + readerState.latestStepFailure + && readerState.latestStepFailure.status === 'quality_guard_failed' + && readerState.continuityContract + && readerState.continuityContract.chapter_context_retained + ); + const job = readerState.readerGenerationJob || {}; + return { + ready: latestOk || qualityGuardHandled, + latest_ok: latestOk, + quality_guard_handled: qualityGuardHandled, + turn_index: Number(readerState.currentState?.turn_index || 0), + has_chosen_event: Boolean(readerState.latestStep?.chosen_event), + failure_status: readerState.latestStepFailure?.status || '', + continuity_status: readerState.continuityContract?.status || '', + reader_generation_job: { + jobId: job.jobId || '', + status: job.status || '', + readerStatus: job.readerStatus || '', + phase: job.phase || '', + error: job.error || '', + retryable: Boolean(job.retryable), + }, + }; + })()`); + if (latestSnapshot.ready) return latestSnapshot; + const jobStatus = String(latestSnapshot.reader_generation_job?.status || ""); + if (jobStatus === "failed") { + const error = new Error(latestSnapshot.reader_generation_job?.error || "reader_job_failed"); + error.code = "reader_job_failed"; + error.readerSnapshot = latestSnapshot; + throw error; + } + await sleep(250); + } + const jobStatus = String(latestSnapshot.reader_generation_job?.status || ""); + const code = + jobStatus === "queued" || jobStatus === "running" + ? "reader_job_timeout" + : jobStatus === "succeeded" + ? "reader_ui_sync_stale" + : "reader_ui_sync_stale"; + const error = new Error(`${code}: Timed out waiting for reader step complete`); + error.code = code; + error.readerSnapshot = latestSnapshot; + throw error; +} + +async function clickSelector(evaluate, selector) { + const escaped = JSON.stringify(selector); + return evaluate(`(() => { + const el = document.querySelector(${escaped}); + if (!el) throw new Error('Missing selector: ' + ${escaped}); + el.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 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 clickButtonByAnyText(evaluate, selector, labels) { + const escapedSelector = JSON.stringify(selector); + const escapedLabels = JSON.stringify(labels || []); + return evaluate(`(() => { + const allowed = new Set(${escapedLabels}); + const button = Array.from(document.querySelectorAll(${escapedSelector})).find((item) => allowed.has(item.textContent.trim())); + if (!button) throw new Error('Missing button from labels: ' + Array.from(allowed).join(' / ')); + button.click(); + return button.textContent.trim(); + })()`); +} + +async function countVisiblePanels(evaluate, selector) { + return evaluate(`(() => [...document.querySelectorAll(${JSON.stringify(selector)})].filter((node) => node.offsetParent !== null).length)()`); +} + +async function waitForAuthorRepairLoopEditor(evaluate, assetType) { + const expressions = { + scene_blueprint: `new URL(location.href).searchParams.get('workspace') === 'draft' + && document.querySelector('#author-scene-select') + && document.querySelector('#author-scene-select').options.length > 0 + && (document.querySelector('#author-scene-summary')?.innerText || '').length > 0`, + character_card: `new URL(location.href).searchParams.get('workspace') === 'draft' + && document.querySelector('#author-character-select') + && document.querySelector('#author-character-select').options.length > 0 + && (document.querySelector('#author-character-summary')?.innerText || '').length > 0`, + chapter_task: `new URL(location.href).searchParams.get('workspace') === 'draft' + && document.querySelector('#author-task-select') + && document.querySelector('#author-task-select').options.length > 0 + && (document.querySelector('#author-longform-summary')?.innerText || '').length > 0`, + }; + const expression = expressions[assetType]; + if (!expression) { + throw new Error(`Unsupported repair-loop asset type: ${assetType}`); + } + await waitFor(evaluate, `author ${assetType} editor`, expression, 30000); +} + +async function mutateAuthorRepairLoopAsset(evaluate, assetType, mutationToken) { + if (assetType === "scene_blueprint") { + const current = await evaluate(`document.querySelector('#author-scene-beats')?.value || ''`); + await setValue( + evaluate, + "#author-scene-beats", + [String(current || "").trim(), mutationToken].filter(Boolean).join("\n") + ); + await clickSelector(evaluate, "#author-save-scene"); + return; + } + if (assetType === "character_card") { + const current = await evaluate(`document.querySelector('#author-character-life-theme')?.value || ''`); + await setValue( + evaluate, + "#author-character-life-theme", + [String(current || "").trim(), mutationToken].filter(Boolean).join(" | ") + ); + await clickSelector(evaluate, "#author-save-character"); + return; + } + if (assetType === "chapter_task") { + const current = await evaluate(`document.querySelector('#author-task-objective')?.value || ''`); + await setValue( + evaluate, + "#author-task-objective", + [String(current || "").trim(), mutationToken].filter(Boolean).join("\n") + ); + await clickSelector(evaluate, "#author-save-longform"); + return; + } + throw new Error(`Unsupported repair-loop asset mutation: ${assetType}`); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const url = args.url; + const appUrl = new URL(url); + const chromePort = Number(args["chrome-port"] || 9224); + const scope = String(args.scope || "full").trim(); + const databaseUrl = args["database-url"]; + const resultFile = args["result-file"]; + const failureArtifactFile = args["failure-artifact-file"]; + const failureScreenshotFile = args["failure-screenshot-file"]; + const isReaderOnlyScope = scope === "reader"; + const GUARD = { + id: isReaderOnlyScope ? "reader_shell_smoke" : "frontend_shell_smoke", + label: isReaderOnlyScope ? "Reader Shell Smoke" : "Frontend Shell Smoke", + }; + const suiteScope = isReaderOnlyScope ? "reader_shell_smoke" : "frontend_shell_smoke"; + if (!url || !databaseUrl || !resultFile || !failureArtifactFile || !failureScreenshotFile) { + throw new Error("Usage: node verify_frontend_shell_smoke.js --url --database-url --result-file --failure-artifact-file --failure-screenshot-file [--chrome-port ] [--scope ]"); + } + + 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 writeFailureScreenshot = (base64Png) => { + fs.mkdirSync(path.dirname(failureScreenshotFile), { recursive: true }); + fs.writeFileSync(failureScreenshotFile, Buffer.from(base64Png, "base64")); + }; + const buildResultPayload = ({ status, failedStep, summary = {}, consoleErrors, errorMessage = null, failureArtifact = null, failureScreenshot = null }) => ({ + status, + schema_version: "frontend_qa_result/v1", + guard: GUARD, + summary_meta: { + primary_key: "completed_steps", + primary_count: stepOrder.length, + summary_key_count: Object.keys(summary || {}).length, + }, + artifacts: { + result_file: resultFile, + failure_artifact_file: failureArtifact, + failure_screenshot_file: failureScreenshot, + }, + app_url: url, + completed_steps: stepOrder, + failed_step: failedStep, + console_errors: consoleErrors, + ...(errorMessage ? { error_message: errorMessage } : {}), + summary, + failure_artifact_file: failureArtifact, + failure_screenshot_file: failureScreenshot, + }); + + const stepOrder = []; + let currentStep = "bootstrap"; + let readerShellSmokeActorId = ""; + const markStep = (step) => { + currentStep = step; + }; + const completeStep = (step) => { + stepOrder.push(step); + }; + + await openAppTarget(chromePort, url); + await sleep(1000); + const { ws, evaluate, send, consoleErrors } = await connectToPage(url, chromePort); + + const captureFailureSnapshot = async () => { + try { + return await evaluate(`({ + title: document.title || "", + url: location.href || "", + product: document.querySelector('#app-shell')?.dataset.product || "", + workspace: new URL(location.href).searchParams.get('workspace') || "", + readerLanding: document.querySelector('#reader-landing')?.innerText?.slice(0, 1200) || "", + reader_generation_job: typeof readerState !== 'undefined' ? readerState.readerGenerationJob || null : null, + reader_latest_step: typeof readerState !== 'undefined' ? { + turn_index: Number(readerState.currentState?.turn_index || 0), + has_chosen_event: Boolean(readerState.latestStep?.chosen_event), + failure_status: readerState.latestStepFailure?.status || '', + continuity_status: readerState.continuityContract?.status || '' + } : null, + authorShell: document.querySelector('#author-shell')?.innerText?.slice(0, 1200) || "", + opsShell: document.querySelector('#ops-shell')?.innerText?.slice(0, 1200) || "", + 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 captureFailureScreenshot = async () => { + try { + const result = await send("Page.captureScreenshot", { + format: "png", + fromSurface: true, + }); + writeFailureScreenshot(result.data); + return { + screenshot_file: failureScreenshotFile, + screenshot_error: null, + }; + } catch (error) { + return { + screenshot_file: null, + screenshot_error: error && error.message ? error.message : String(error), + }; + } + }; + + 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, + "frontend shell bootstrap", + `window.__bootMarker === 'after-init' + && typeof ReaderRuntime === 'object' + && typeof AuthorWorkspaceRuntime === 'object' + && typeof OpsRuntime === 'object' + && typeof ShellRuntime === 'object' + && typeof ShellDOM === 'object' + && typeof ReaderDOM === 'object' + && typeof AuthorDOM === 'object' + && typeof OpsDOM === 'object' + && typeof DOMShared === 'object'`, + 30000 + ); + completeStep("wait_for_bootstrap"); + + markStep("bootstrap_reader_auth"); + readerShellSmokeActorId = `reader_shell_${Date.now()}`; + const readerShellSmokePassword = "secret123"; + await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: "/v1/auth/register", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + actor_id: readerShellSmokeActorId, + actor_role: "reader", + password: readerShellSmokePassword, + account_id: readerShellSmokeActorId, + display_name: "Reader Shell Smoke", + }), + }); + const readerShellLogin = await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: "/v1/auth/login", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + actor_id: readerShellSmokeActorId, + password: readerShellSmokePassword, + }), + }); + await evaluate(`(async () => { + await ReaderRuntime.mirrorReaderAuthSession(${JSON.stringify({ + accessToken: readerShellLogin.token?.access_token || "", + expiresAt: readerShellLogin.token?.expires_at || null, + identity: readerShellLogin.identity || null, + tokenType: readerShellLogin.token?.token_type || "bearer", + })}); + return true; + })()`); + completeStep("bootstrap_reader_auth"); + + markStep("verify_reader_landing"); + await waitFor( + evaluate, + "reader landing worlds", + `document.querySelector('#app-shell')?.dataset.product === 'reader' + && new URL(location.href).searchParams.get('workspace') === 'landing' + && document.querySelector('#reader-shell-v2') + && document.querySelectorAll('#reader-v2-worlds-list article').length >= 2`, + 30000 + ); + completeStep("verify_reader_landing"); + + markStep("enter_reader_workspace"); + await clickButtonByText(evaluate, "#reader-shell-v2 button", "开始旅程"); + await waitFor( + evaluate, + "reader read workspace", + `document.querySelector('#app-shell')?.dataset.product === 'reader' + && new URL(location.href).searchParams.get('workspace') === 'read' + && document.querySelector('#reader-shell-v2') + && document.querySelector('#reader-v2-read-hero') + && document.querySelector('#reader-v2-main-column')`, + 30000 + ); + const readerSessionIdAfterBootstrap = await evaluate(`String(readerState.sessionId || '')`); + if (!readerSessionIdAfterBootstrap) { + throw new Error("Reader bootstrap did not create a session id."); + } + completeStep("enter_reader_workspace"); + + markStep("restore_reader_workspace"); + await clickButtonByText(evaluate, "#reader-v2-read-hero button", "返回书架"); + await waitFor( + evaluate, + "reader landing after bootstrap", + `document.querySelector('#app-shell')?.dataset.product === 'reader' + && new URL(location.href).searchParams.get('workspace') === 'landing' + && document.querySelector('#reader-v2-spotlight') + && document.querySelectorAll('#reader-v2-sessions-list article').length >= 1`, + 30000 + ); + await clickButtonByText(evaluate, "#reader-v2-sessions-list button", "继续阅读"); + await waitFor( + evaluate, + "reader restored workspace", + `document.querySelector('#app-shell')?.dataset.product === 'reader' + && new URL(location.href).searchParams.get('workspace') === 'read' + && String(readerState.sessionId || '') === ${JSON.stringify(readerSessionIdAfterBootstrap)} + && document.querySelector('#reader-v2-read-hero') + && document.querySelector('#reader-v2-main-column')`, + 30000 + ); + completeStep("restore_reader_workspace"); + + markStep("step_reader_once"); + await setValue(evaluate, "#reader-shell-v2-input", "我先试探一下眼前这条路到底会把我带向哪里。"); + await waitFor( + evaluate, + "reader step enabled", + `document.querySelector('[data-reader-v2-action="step-session"]') && document.querySelector('[data-reader-v2-action="step-session"]').disabled === false`, + 30000 + ); + await clickSelector(evaluate, '[data-reader-v2-action="step-session"]'); + const readerStepSnapshot = await waitForReaderStepComplete(evaluate); + const readerTurnAfterStep = Number(readerStepSnapshot.turn_index || 0); + completeStep("step_reader_once"); + + markStep("force_reader_gating"); + const gatedSessionId = await evaluate(`String(readerState.sessionId || '')`); + if (!gatedSessionId) { + throw new Error("Missing reader session id before gating smoke."); + } + childProcess.execFileSync( + ".venv/bin/python", + [ + "scripts/force_reader_paid_chapter.py", + "--database-url", + databaseUrl, + "--session-id", + gatedSessionId, + "--chapter-index", + "3", + ], + { cwd: process.cwd(), stdio: "pipe" } + ); + completeStep("force_reader_gating"); + + markStep("verify_reader_gating"); + await setValue(evaluate, "#reader-shell-v2-input", "我还想继续看下去。"); + await clickSelector(evaluate, '[data-reader-v2-action="step-session"]'); + await waitFor( + evaluate, + "reader gating inline card", + `typeof readerState !== 'undefined' + && readerState.sessionPaywall + && readerState.sessionPaywall.required === true + && document.querySelector('#reader-v2-paywall-card') + && (document.querySelector('#reader-v2-paywall-card')?.innerText || '').includes('解锁') + && (document.querySelector('#shell-status-banner')?.innerText || '').includes('解锁')`, + 30000 + ); + const gatingSnapshot = await evaluate(`({ + reason: readerState.sessionPaywall?.reason || '', + required_display_name: readerState.sessionPaywall?.required_display_name || '', + unlock_text: document.querySelector('#reader-v2-paywall-card')?.innerText || '', + composer_hint: document.querySelector('#reader-composer-hint')?.innerText || '', + status_banner: document.querySelector('#shell-status-banner')?.innerText || '' + })`); + completeStep("verify_reader_gating"); + + markStep("start_reader_checkout"); + await clickButtonByText(evaluate, "#reader-v2-paywall-card button", "解锁并继续阅读"); + await waitFor( + evaluate, + "reader checkout status recorded", + `typeof readerState !== 'undefined' + && readerState.readerCheckoutSession + && (readerState.readerCheckoutSession.checkout_url || readerState.readerCheckoutSession.checkout_session_id || readerState.readerCheckoutSession.session_id) + && (document.querySelector('#reader-checkout-status')?.innerText || '').length > 0`, + 30000 + ); + const checkoutSnapshot = await evaluate(`({ + tier_id: readerState.readerCheckoutSession?.tier_id || '', + provider: readerState.readerCheckoutSession?.provider || '', + checkout_url: readerState.readerCheckoutSession?.checkout_url || '', + checkout_session_id: readerState.readerCheckoutSession?.checkout_session_id || readerState.readerCheckoutSession?.session_id || '', + checkout_status_text: document.querySelector('#reader-checkout-status')?.innerText || '', + banner_text: document.querySelector('#shell-status-banner')?.innerText || '' + })`); + completeStep("start_reader_checkout"); + + markStep("complete_reader_checkout_webhook"); + await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: "/v1/reader/checkout/webhook", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: checkoutSnapshot.provider || "web_stub", + provider_event_id: `evt_frontend_shell_smoke_${Date.now()}`, + event_type: "checkout_session_completed", + account_id: readerShellSmokeActorId, + checkout_session_id: checkoutSnapshot.checkout_session_id, + payload: { source: "frontend_shell_smoke" }, + }), + }); + await evaluate(`(async () => { + await ReaderRuntime.refreshReaderEntitlements(); + return true; + })()`); + await waitFor( + evaluate, + "reader subscription active after webhook", + `typeof readerState !== 'undefined' + && ((readerState.readerSubscription && readerState.readerSubscription.subscription && readerState.readerSubscription.subscription.status === 'active') + || (document.querySelector('#reader-subscription-status')?.innerText || '') === 'active') + && ((readerState.readerCheckoutSession && readerState.readerCheckoutSession.status === 'completed') + || (document.querySelector('#reader-checkout-status')?.innerText || '').includes('completed'))`, + 30000 + ); + const activatedCheckoutSnapshot = await evaluate(`({ + subscription_status: readerState.readerSubscription?.subscription?.status || document.querySelector('#reader-subscription-status')?.innerText || '', + checkout_status: readerState.readerCheckoutSession?.status || ((document.querySelector('#reader-checkout-status')?.innerText || '').includes('completed') ? 'completed' : ''), + checkout_status_text: document.querySelector('#reader-checkout-status')?.innerText || '' + })`); + completeStep("complete_reader_checkout_webhook"); + + markStep("resume_reader_after_activation"); + await setValue(evaluate, "#reader-shell-v2-input", "现在我可以继续把这条命往前推了。"); + await clickSelector(evaluate, '[data-reader-v2-action="step-session"]'); + await waitFor( + evaluate, + "reader resumed after activation", + `(() => { + if (typeof readerState === 'undefined') return false; + const hasVisibleUnlockCard = Array.from(document.querySelectorAll('#reader-v2-paywall-card')) + .some((node) => node.offsetParent !== null); + const latestOk = Boolean( + readerState.latestStep + && readerState.latestStep.chosen_event + && readerState.currentState + && Number(readerState.currentState.turn_index || 0) > ${Number(readerTurnAfterStep || 0)} + ); + const qualityGuardHandled = (document.querySelector('#shell-status-banner')?.innerText || '').includes('本章未入库'); + return !hasVisibleUnlockCard && (latestOk || qualityGuardHandled); + })()`, + 30000 + ); + const readerResumeSnapshot = await evaluate(`({ + turn_index: Number(readerState.currentState?.turn_index || 0), + has_chosen_event: Boolean(readerState.latestStep?.chosen_event), + status_banner: document.querySelector('#shell-status-banner')?.innerText || '', + })`); + const readerTurnAfterActivation = Number(readerResumeSnapshot.turn_index || 0); + completeStep("resume_reader_after_activation"); + + markStep("reader_storybook_view"); + await clickButtonByText(evaluate, "#product-subnav-actions button", "图文阅读"); + await waitFor( + evaluate, + "reader storybook view", + `typeof readerState !== 'undefined' + && 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 > 0`, + 30000 + ); + const readerStorybookSnapshot = await evaluate(`({ + chapter_title: document.querySelector('#reader-v2-storybook-title')?.innerText || '', + prose_length: (document.querySelector('#reader-v2-storybook-prose')?.innerText || '').trim().length, + sequence_count: document.querySelectorAll('#reader-v2-storybook-sequence article, #reader-v2-storybook-sequence button').length, + view: readerState.activeView || '', + })`); + completeStep("reader_storybook_view"); + + markStep("reader_backstage_view"); + await clickButtonByText(evaluate, "#product-subnav-actions button", "幕后档案"); + await waitFor( + evaluate, + "reader backstage view", + `typeof readerState !== 'undefined' + && readerState.activeView === 'backstage' + && new URL(location.href).searchParams.get('view') === 'backstage' + && document.querySelector('#reader-v2-backstage') + && document.querySelector('#reader-v2-experience, #reader-v2-storybook') + && (document.querySelector('#reader-v2-backstage-copy')?.innerText || '').trim().length > 0`, + 30000 + ); + const readerBackstageSnapshot = await evaluate(`({ + title: document.querySelector('#reader-v2-backstage-title')?.innerText || '', + body: document.querySelector('#reader-v2-backstage-copy')?.innerText || document.querySelector('#reader-v2-backstage')?.innerText || '', + view: readerState.activeView || '', + })`); + await clickSelector(evaluate, "#reader-v2-backstage-close"); + await waitFor( + evaluate, + "reader returns to previous reading view", + `typeof readerState !== 'undefined' + && readerState.activeView === 'storybook' + && new URL(location.href).searchParams.get('view') === 'storybook' + && document.querySelector('#reader-v2-storybook') + && !document.querySelector('#reader-v2-backstage')`, + 30000 + ); + completeStep("reader_backstage_view"); + + if (isReaderOnlyScope) { + if (consoleErrors.length) { + throw new Error(`Reader shell smoke emitted ${consoleErrors.length} console error(s)`); + } + const summary = await evaluate(`({ + reader_world_cards: document.querySelectorAll('#reader-v2-worlds-list article').length, + reader_session_cards: document.querySelectorAll('#reader-v2-sessions-list article').length, + final_product: document.querySelector('#app-shell')?.dataset.product || '', + final_workspace: new URL(location.href).searchParams.get('workspace') || '', + final_view: new URL(location.href).searchParams.get('view') || '' + })`); + const resultPayload = buildResultPayload({ + status: "ok", + failedStep: null, + consoleErrors, + summary: { + headline_metric: "completed_steps", + headline_value: stepOrder.length, + suite_scope: suiteScope, + ...summary, + reader_session_id: readerSessionIdAfterBootstrap, + reader_turn_after_step: readerTurnAfterStep, + reader_gating_reason: gatingSnapshot.reason, + reader_gating_display_name: gatingSnapshot.required_display_name, + reader_checkout_tier: checkoutSnapshot.tier_id, + reader_checkout_provider: checkoutSnapshot.provider, + reader_checkout_status: activatedCheckoutSnapshot.checkout_status, + reader_subscription_status: activatedCheckoutSnapshot.subscription_status, + reader_resume_has_chosen_event: readerResumeSnapshot.has_chosen_event, + reader_resume_status_banner: readerResumeSnapshot.status_banner, + reader_turn_after_activation: readerTurnAfterActivation, + reader_storybook_title: readerStorybookSnapshot.chapter_title, + reader_storybook_prose_length: readerStorybookSnapshot.prose_length, + reader_storybook_sequence_count: readerStorybookSnapshot.sequence_count, + reader_backstage_title: readerBackstageSnapshot.title, + reader_backstage_copy_length: (readerBackstageSnapshot.body || "").trim().length, + }, + }); + writeResult(resultPayload); + console.log(JSON.stringify(resultPayload, null, 2)); + return; + } + + markStep("enter_author_workspace"); + await clickSelector(evaluate, "#mode-author"); + await evaluate(`(() => { + if (typeof WorkspaceLayoutRuntime !== 'undefined') { + WorkspaceLayoutRuntime.setAuthorWorkspace('overview'); + } + if (typeof ShellStatusRuntime !== 'undefined') { + ShellStatusRuntime.syncProductMode(); + } + return true; + })()`); + await waitFor( + evaluate, + "author workspace", + `document.querySelector('#app-shell')?.dataset.product === 'author' + && new URL(location.href).searchParams.get('workspace') === 'overview' + && document.querySelector('#author-shell') + && !document.querySelector('#author-shell').classList.contains('is-hidden')`, + 30000 + ); + const authorVisiblePanels = await countVisiblePanels(evaluate, "#author-shell .panel"); + completeStep("enter_author_workspace"); + + markStep("author_refresh_once"); + await clickSelector(evaluate, "#author-refresh"); + await waitFor( + evaluate, + "author refresh settles", + `document.querySelector('#app-shell')?.dataset.product === 'author' + && new URL(location.href).searchParams.get('workspace') === 'overview' + && document.querySelector('#author-refresh') + && document.querySelector('#author-refresh').disabled === false`, + 30000 + ); + completeStep("author_refresh_once"); + + const authorActorId = `author_shell_smoke_${Date.now()}`; + const authorPassword = "smoke-pass-123"; + const authorDraftTitle = `Smoke Draft ${Date.now()}`; + const authorLeadName = `Lead ${Date.now()}`; + const opsActingReviewerId = `ops_shell_smoke_${Date.now()}`; + const opsActingReviewerPassword = "smoke-pass-123"; + const opsAssignedOwnerId = `ops_shell_smoke_owner_${Date.now()}`; + const opsAssignedOwnerPassword = "smoke-pass-123"; + + markStep("author_open_settings"); + await clickButtonByText(evaluate, "#product-subnav-actions button", "账户协作"); + await waitFor( + evaluate, + "author settings workspace", + `document.querySelector('#app-shell')?.dataset.product === 'author' + && new URL(location.href).searchParams.get('workspace') === 'settings'`, + 30000 + ); + completeStep("author_open_settings"); + + markStep("author_register_login"); + await setValue(evaluate, "#author-auth-actor-id", authorActorId); + await setValue(evaluate, "#author-account-id", authorActorId); + await setValue(evaluate, "#author-auth-display-name", "Frontend Shell Smoke Author"); + await setValue(evaluate, "#author-auth-password", authorPassword); + await clickSelector(evaluate, "#author-auth-register"); + await waitFor( + evaluate, + "author auth status", + `(document.querySelector('#author-auth-status')?.innerText || '').includes(${JSON.stringify(authorActorId)})`, + 30000 + ); + completeStep("author_register_login"); + + await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: "/v1/auth/register", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + actor_id: opsActingReviewerId, + actor_role: "reviewer", + password: opsActingReviewerPassword, + account_id: opsActingReviewerId, + display_name: "Frontend Shell Smoke Acting Reviewer", + }), + }); + await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: "/v1/auth/register", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + actor_id: opsAssignedOwnerId, + actor_role: "reviewer", + password: opsAssignedOwnerPassword, + account_id: opsAssignedOwnerId, + display_name: "Frontend Shell Smoke Assigned Owner", + }), + }); + const opsActingReviewerLogin = await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: "/v1/auth/login", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + actor_id: opsActingReviewerId, + password: opsActingReviewerPassword, + }), + }); + const opsActingReviewerAccessToken = String(opsActingReviewerLogin.token?.access_token || ""); + if (!opsActingReviewerAccessToken) { + throw new Error("Acting reviewer login did not return an access token."); + } + const opsBridgePayload = await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: "/v1/auth/admin-view-session-bridge", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + actor_id: opsActingReviewerId, + password: opsActingReviewerPassword, + account_id: authorActorId, + workspace: "account", + }), + }); + await evaluate(`(() => { + shellState.adminViewBridgeToken = ${JSON.stringify(opsBridgePayload.bridge?.token || "")}; + shellState.adminViewEnabled = true; + if (typeof window !== 'undefined') { + window.sessionStorage.setItem('narrativeos_admin_view_bridge', ${JSON.stringify(opsBridgePayload.bridge?.token || "")}); + } + return true; + })()`); + + markStep("enter_ops_workspace"); + await clickSelector(evaluate, "#mode-ops"); + await waitFor( + evaluate, + "ops workspace", + `document.querySelector('#app-shell')?.dataset.product === 'ops' + && new URL(location.href).searchParams.get('workspace') === 'dashboard' + && document.querySelector('#ops-shell') + && !document.querySelector('#ops-shell').classList.contains('is-hidden')`, + 30000 + ); + const opsVisiblePanels = await countVisiblePanels(evaluate, "#ops-shell .panel"); + completeStep("enter_ops_workspace"); + + markStep("ops_switch_review"); + await clickButtonByAnyText(evaluate, "#product-subnav-actions button", ["统一审阅台", "审核队列"]); + await waitFor( + evaluate, + "ops review workspace", + `document.querySelector('#app-shell')?.dataset.product === 'ops' + && new URL(location.href).searchParams.get('workspace') === 'review'`, + 30000 + ); + const opsReviewWorkspace = await evaluate(`new URL(location.href).searchParams.get('workspace') || ''`); + completeStep("ops_switch_review"); + + markStep("ops_switch_account"); + await clickButtonByText(evaluate, "#product-subnav-actions button", "账户排查"); + await waitFor( + evaluate, + "ops account workspace", + `document.querySelector('#app-shell')?.dataset.product === 'ops' + && new URL(location.href).searchParams.get('workspace') === 'account'`, + 30000 + ); + const opsAccountWorkspace = await evaluate(`new URL(location.href).searchParams.get('workspace') || ''`); + completeStep("ops_switch_account"); + + markStep("ops_grant_subscription"); + await setValue(evaluate, "#ops-account-id", authorActorId); + await setValue(evaluate, "#ops-tier-id", "creator_pass"); + await clickSelector(evaluate, "#ops-grant-subscription"); + await waitFor( + evaluate, + "ops subscription grant recorded", + `(() => { + const audit = document.querySelector('#ops-subscription-audit')?.innerText || ''; + const detail = document.querySelector('#ops-account-detail')?.innerText || ''; + return audit.includes(${JSON.stringify(authorActorId)}) && (audit.includes('creator_pass') || detail.includes('creator_pass')); + })()`, + 30000 + ); + const opsMutationSnapshot = await evaluate(`({ + subscription_audit_text: document.querySelector('#ops-subscription-audit')?.innerText || '', + account_detail_text: document.querySelector('#ops-account-detail')?.innerText || '', + granted_tier: (opsState.opsSubscriptionAudit?.subscriptions || [])[0]?.tier_id || '' + })`); + completeStep("ops_grant_subscription"); + + const governanceSummary = `Frontend shell smoke governance case ${Date.now()}`; + + markStep("ops_create_governance_case"); + const governanceCreatePayload = await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: "/v1/ops/governance/cases", + headers: { + "Content-Type": "application/json", + "X-NarrativeOS-Actor-Id": opsActingReviewerId, + "X-NarrativeOS-Actor-Role": "reviewer", + "X-NarrativeOS-Account-Id": authorActorId, + }, + body: JSON.stringify({ + case_type: "rights", + target_type: "account", + target_id: authorActorId, + account_id: authorActorId, + severity: "medium", + summary: governanceSummary, + description: "frontend shell smoke governance write path", + reviewer_id: opsActingReviewerId, + owner_id: opsActingReviewerId, + policy_labels: ["billing_rights", "smoke_case"], + evidence_refs: [ + { + title: "smoke_note", + preview: "created by frontend shell smoke", + kind: "note", + }, + ], + }), + }); + if (!governanceCreatePayload.case?.case_id) { + throw new Error("Governance case create did not return a case id."); + } + + await evaluate(`(async () => { + await OpsRefreshRuntime.refreshOpsAccountFlow(); + return true; + })()`); + await waitFor( + evaluate, + "ops governance case created", + `(() => { + const list = document.querySelector('#ops-governance-cases')?.innerText || ''; + const summary = document.querySelector('#ops-governance-summary')?.innerText || ''; + return typeof opsState !== 'undefined' + && opsState.opsGovernanceSnapshot + && (opsState.opsGovernanceSnapshot.governance_cases || []).some((item) => item.case_id === ${JSON.stringify(governanceCreatePayload.case.case_id)}) + && list.includes(${JSON.stringify(governanceSummary)}) + && Number(opsState.opsGovernanceSnapshot?.governance_summary?.open_case_count || 0) > 0; + })()`, + 30000 + ); + const opsGovernanceSnapshot = await evaluate(`({ + case_id: (opsState.opsGovernanceSnapshot?.governance_cases || []).find((item) => item.case_id === ${JSON.stringify(governanceCreatePayload.case.case_id)})?.case_id || '', + cases_text: document.querySelector('#ops-governance-cases')?.innerText || '', + export_text: document.querySelector('#ops-governance-export')?.innerText || '', + case_status: (opsState.opsGovernanceSnapshot?.governance_cases || []).find((item) => item.case_id === ${JSON.stringify(governanceCreatePayload.case.case_id)})?.status || ${JSON.stringify(governanceCreatePayload.case.status || "")}, + case_type: (opsState.opsGovernanceSnapshot?.governance_cases || []).find((item) => item.case_id === ${JSON.stringify(governanceCreatePayload.case.case_id)})?.case_type || ${JSON.stringify(governanceCreatePayload.case.case_type || "")}, + case_severity: (opsState.opsGovernanceSnapshot?.governance_cases || []).find((item) => item.case_id === ${JSON.stringify(governanceCreatePayload.case.case_id)})?.severity || ${JSON.stringify(governanceCreatePayload.case.severity || "medium")}, + case_target_type: (opsState.opsGovernanceSnapshot?.governance_cases || []).find((item) => item.case_id === ${JSON.stringify(governanceCreatePayload.case.case_id)})?.target_type || ${JSON.stringify(governanceCreatePayload.case.target_type || "account")}, + case_target_id: (opsState.opsGovernanceSnapshot?.governance_cases || []).find((item) => item.case_id === ${JSON.stringify(governanceCreatePayload.case.case_id)})?.target_id || ${JSON.stringify(governanceCreatePayload.case.target_id || authorActorId)}, + open_case_count: Number(opsState.opsGovernanceSnapshot?.governance_summary?.open_case_count || 0) + })`); + completeStep("ops_create_governance_case"); + + markStep("ops_transition_governance_case"); + const governanceTransitionPayload = await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: `/v1/ops/governance/cases/${encodeURIComponent(opsGovernanceSnapshot.case_id)}/status`, + headers: { + "Content-Type": "application/json", + "X-NarrativeOS-Actor-Id": opsActingReviewerId, + "X-NarrativeOS-Actor-Role": "reviewer", + "X-NarrativeOS-Account-Id": authorActorId, + }, + body: JSON.stringify({ + status: "in_review", + reviewer_id: opsActingReviewerId, + resolution_notes: "frontend shell smoke status transition", + }), + }); + if (!governanceTransitionPayload.case?.case_id) { + throw new Error("Governance status transition did not return a case id."); + } + await evaluate(`(async () => { + await OpsRefreshRuntime.refreshOpsAccountFlow(); + return true; + })()`); + await waitFor( + evaluate, + "ops governance case transitioned", + `(() => { + const list = document.querySelector('#ops-governance-cases')?.innerText || ''; + return typeof opsState !== 'undefined' + && opsState.opsGovernanceSnapshot + && (opsState.opsGovernanceSnapshot.governance_cases || []).some((item) => item.case_id === ${JSON.stringify(opsGovernanceSnapshot.case_id)} && item.status === 'in_review') + && list.includes(${JSON.stringify(governanceSummary)}); + })()`, + 30000 + ); + const opsGovernanceTransitionSnapshot = await evaluate(`({ + case_id: (opsState.opsGovernanceSnapshot?.governance_cases || []).find((item) => item.case_id === ${JSON.stringify(opsGovernanceSnapshot.case_id)})?.case_id || '', + case_status_after_transition: (opsState.opsGovernanceSnapshot?.governance_cases || []).find((item) => item.case_id === ${JSON.stringify(opsGovernanceSnapshot.case_id)})?.status || ${JSON.stringify(governanceTransitionPayload.case.status || "")}, + case_summary_after_transition: (opsState.opsGovernanceSnapshot?.governance_cases || []).find((item) => item.case_id === ${JSON.stringify(opsGovernanceSnapshot.case_id)})?.summary || ${JSON.stringify(governanceSummary)}, + cases_text: document.querySelector('#ops-governance-cases')?.innerText || '' + })`); + completeStep("ops_transition_governance_case"); + + const governanceEvidenceTitle = `smoke_followup_note_${Date.now()}`; + const governanceEvidencePreview = "frontend shell smoke governance evidence append"; + + markStep("ops_add_governance_evidence"); + const governanceEvidencePayload = await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: `/v1/ops/governance/cases/${encodeURIComponent(opsGovernanceSnapshot.case_id)}/evidence`, + headers: { + "Content-Type": "application/json", + "X-NarrativeOS-Actor-Id": opsActingReviewerId, + "X-NarrativeOS-Actor-Role": "reviewer", + "X-NarrativeOS-Account-Id": authorActorId, + }, + body: JSON.stringify({ + reviewer_id: opsActingReviewerId, + title: governanceEvidenceTitle, + preview: governanceEvidencePreview, + kind: "note", + }), + }); + if (!governanceEvidencePayload.case?.case_id) { + throw new Error("Governance evidence append did not return a case id."); + } + await evaluate(`(async () => { + await OpsRefreshRuntime.refreshOpsAccountFlow(); + await OpsActionsRuntime.openGovernanceCaseDetail(${JSON.stringify(opsGovernanceSnapshot.case_id)}); + return true; + })()`); + await waitFor( + evaluate, + "ops governance evidence appended", + `(() => { + const detail = document.querySelector('#ops-governance-detail')?.innerText || ''; + return typeof opsState !== 'undefined' + && opsState.opsGovernanceDetail + && opsState.opsGovernanceDetail.case_id === ${JSON.stringify(opsGovernanceSnapshot.case_id)} + && Array.isArray(opsState.opsGovernanceDetail.evidence_refs) + && opsState.opsGovernanceDetail.evidence_refs.length > ${Number((governanceEvidencePayload.case.evidence_refs || []).length - 1)} + && detail.includes(${JSON.stringify(governanceEvidenceTitle)}) + && detail.includes(${JSON.stringify(governanceEvidencePreview)}); + })()`, + 30000 + ); + const opsGovernanceEvidenceSnapshot = await evaluate(`({ + case_id: opsState.opsGovernanceDetail?.case_id || '', + evidence_count_after_append: Array.isArray(opsState.opsGovernanceDetail?.evidence_refs) ? opsState.opsGovernanceDetail.evidence_refs.length : 0, + latest_evidence_title: (opsState.opsGovernanceDetail?.evidence_refs || []).slice(-1)[0]?.title || '', + latest_evidence_preview: (opsState.opsGovernanceDetail?.evidence_refs || []).slice(-1)[0]?.preview || '', + detail_text: document.querySelector('#ops-governance-detail')?.innerText || '' + })`); + completeStep("ops_add_governance_evidence"); + + const governanceRestrictionSummary = `Frontend shell smoke restriction ${Date.now()}`; + + markStep("ops_apply_governance_restriction"); + const governanceRestrictionPayload = await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: "/v1/ops/governance/restrictions", + headers: { + "Content-Type": "application/json", + "X-NarrativeOS-Actor-Id": opsActingReviewerId, + "X-NarrativeOS-Actor-Role": "reviewer", + "X-NarrativeOS-Account-Id": authorActorId, + }, + body: JSON.stringify({ + restriction_type: "checkout_block", + account_id: authorActorId, + case_type: "abuse", + severity: "high", + summary: governanceRestrictionSummary, + description: "frontend shell smoke governance restriction apply", + reviewer_id: opsActingReviewerId, + restriction_reason: "frontend shell smoke checkout enforcement", + }), + }); + if (!governanceRestrictionPayload.case?.case_id) { + throw new Error("Governance restriction apply did not return a case id."); + } + await evaluate(`(async () => { + await OpsRefreshRuntime.refreshOpsAccountFlow(); + await OpsActionsRuntime.openGovernanceCaseDetail(${JSON.stringify(governanceRestrictionPayload.case.case_id)}); + return true; + })()`); + await waitFor( + evaluate, + "ops governance restriction applied", + `(() => { + const detail = document.querySelector('#ops-governance-detail')?.innerText || ''; + const summary = document.querySelector('#ops-governance-summary')?.innerText || ''; + return typeof opsState !== 'undefined' + && opsState.opsGovernanceDetail + && opsState.opsGovernanceDetail.case_id === ${JSON.stringify(governanceRestrictionPayload.case.case_id)} + && opsState.opsGovernanceDetail.status === 'escalated' + && opsState.opsGovernanceDetail.restriction + && opsState.opsGovernanceDetail.restriction.status === 'active' + && opsState.opsGovernanceDetail.restriction.restriction_type === 'checkout_block' + && Number(opsState.opsGovernanceSnapshot?.restriction_summary?.active_restriction_count || 0) > 0 + && detail.includes('checkout_block') + && Number(opsState.opsGovernanceSnapshot?.restriction_summary?.active_restriction_count || 0) > 0; + })()`, + 30000 + ); + const opsGovernanceRestrictionSnapshot = await evaluate(`({ + case_id: opsState.opsGovernanceDetail?.case_id || '', + case_status: opsState.opsGovernanceDetail?.status || '', + restriction_id: opsState.opsGovernanceDetail?.restriction?.restriction_id || '', + restriction_type: opsState.opsGovernanceDetail?.restriction?.restriction_type || '', + restriction_status: opsState.opsGovernanceDetail?.restriction?.status || '', + active_restriction_count: Number(opsState.opsGovernanceSnapshot?.restriction_summary?.active_restriction_count || 0), + detail_text: document.querySelector('#ops-governance-detail')?.innerText || '' + })`); + completeStep("ops_apply_governance_restriction"); + + markStep("ops_release_governance_restriction"); + const governanceReleasePayload = await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: `/v1/ops/governance/restrictions/${encodeURIComponent(opsGovernanceRestrictionSnapshot.restriction_id)}/release`, + headers: { + "Content-Type": "application/json", + "X-NarrativeOS-Actor-Id": opsActingReviewerId, + "X-NarrativeOS-Actor-Role": "reviewer", + "X-NarrativeOS-Account-Id": authorActorId, + }, + body: JSON.stringify({ + reviewer_id: opsActingReviewerId, + release_reason: "frontend shell smoke restriction release", + }), + }); + if (!governanceReleasePayload.case?.case_id) { + throw new Error("Governance restriction release did not return a case id."); + } + await evaluate(`(async () => { + await OpsRefreshRuntime.refreshOpsAccountFlow(); + await OpsActionsRuntime.openGovernanceCaseDetail(${JSON.stringify(opsGovernanceRestrictionSnapshot.case_id)}); + return true; + })()`); + await waitFor( + evaluate, + "ops governance restriction released", + `(() => { + const detail = document.querySelector('#ops-governance-detail')?.innerText || ''; + const summary = document.querySelector('#ops-governance-summary')?.innerText || ''; + return typeof opsState !== 'undefined' + && opsState.opsGovernanceDetail + && opsState.opsGovernanceDetail.case_id === ${JSON.stringify(opsGovernanceRestrictionSnapshot.case_id)} + && opsState.opsGovernanceDetail.status === 'resolved' + && opsState.opsGovernanceDetail.restriction + && opsState.opsGovernanceDetail.restriction.status === 'released' + && Number(opsState.opsGovernanceSnapshot?.restriction_summary?.active_restriction_count || 0) === 0 + && Number(opsState.opsGovernanceSnapshot?.restriction_summary?.active_restriction_count || 0) === 0; + })()`, + 30000 + ); + const opsGovernanceReleaseSnapshot = await evaluate(`({ + case_id: opsState.opsGovernanceDetail?.case_id || '', + case_status_after_release: opsState.opsGovernanceDetail?.status || '', + restriction_status_after_release: opsState.opsGovernanceDetail?.restriction?.status || '', + active_restriction_count_after_release: Number(opsState.opsGovernanceSnapshot?.restriction_summary?.active_restriction_count || 0), + detail_text: document.querySelector('#ops-governance-detail')?.innerText || '' + })`); + completeStep("ops_release_governance_restriction"); + + const governanceOwnerRosterPayload = await httpJson({ + method: "GET", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: `/api/v1/ops/workspaces/governance?accountId=${encodeURIComponent(authorActorId)}`, + headers: { + "Authorization": `Bearer ${opsActingReviewerAccessToken}`, + }, + }); + const governanceOwnerRoster = (governanceOwnerRosterPayload.data?.ownerRoster || []).map((item) => ({ + actorId: String(item.actorId || ""), + actorRole: String(item.actorRole || ""), + status: String(item.status || ""), + })); + const assignedOwnerRosterEntry = governanceOwnerRoster.find((item) => item.actorId === opsAssignedOwnerId); + if (!assignedOwnerRosterEntry || assignedOwnerRosterEntry.actorRole !== "reviewer" || assignedOwnerRosterEntry.status !== "active") { + throw new Error(`Assigned owner reviewer missing from owner roster: ${JSON.stringify(governanceOwnerRoster)}`); + } + + markStep("ops_assign_governance_case_owner"); + const governanceAssignPayload = await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: `/v1/ops/governance/cases/${encodeURIComponent(opsGovernanceSnapshot.case_id)}/assign`, + headers: { + "Content-Type": "application/json", + "X-NarrativeOS-Actor-Id": opsActingReviewerId, + "X-NarrativeOS-Actor-Role": "reviewer", + "X-NarrativeOS-Account-Id": authorActorId, + }, + body: JSON.stringify({ + owner_id: opsAssignedOwnerId, + reviewer_id: opsActingReviewerId, + note: "frontend shell smoke owner assignment", + }), + }); + if (!governanceAssignPayload.case?.case_id) { + throw new Error("Governance case assign did not return a case id."); + } + await evaluate(`(async () => { + await OpsRefreshRuntime.refreshOpsAccountFlow(); + await OpsActionsRuntime.openGovernanceCaseDetail(${JSON.stringify(opsGovernanceSnapshot.case_id)}); + return true; + })()`); + await waitFor( + evaluate, + "ops governance case assigned", + `(() => { + const detail = document.querySelector('#ops-governance-detail')?.innerText || ''; + return typeof opsState !== 'undefined' + && opsState.opsGovernanceDetail + && opsState.opsGovernanceDetail.case_id === ${JSON.stringify(opsGovernanceSnapshot.case_id)} + && (opsState.opsGovernanceDetail.workflow_summary?.owner_id || opsState.opsGovernanceDetail.owner_id || '') === ${JSON.stringify(opsAssignedOwnerId)} + && detail.includes(${JSON.stringify(opsAssignedOwnerId)}); + })()`, + 30000 + ); + const opsGovernanceAssignmentSnapshot = await evaluate(`({ + case_id: opsState.opsGovernanceDetail?.case_id || '', + owner_id_after_assignment: opsState.opsGovernanceDetail?.workflow_summary?.owner_id || opsState.opsGovernanceDetail?.owner_id || '', + owner_roster_size: ${Number(governanceOwnerRoster.length)}, + detail_text: document.querySelector('#ops-governance-detail')?.innerText || '' + })`); + completeStep("ops_assign_governance_case_owner"); + + markStep("ops_non_owner_resolve_rejected"); + const nonOwnerResolvePayload = await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: `/v1/ops/governance/cases/${encodeURIComponent(opsGovernanceSnapshot.case_id)}/status`, + headers: { + "Content-Type": "application/json", + "X-NarrativeOS-Actor-Id": opsActingReviewerId, + "X-NarrativeOS-Actor-Role": "reviewer", + "X-NarrativeOS-Account-Id": authorActorId, + }, + body: JSON.stringify({ + status: "resolved", + reviewer_id: opsActingReviewerId, + resolution_notes: "frontend shell smoke non-owner resolve should fail", + disposition: "customer_remedy_applied", + }), + }).then( + (payload) => ({ ok: true, payload }), + (error) => ({ ok: false, error: error && error.message ? error.message : String(error) }) + ); + if (nonOwnerResolvePayload.ok) { + throw new Error("Non-owner resolve unexpectedly succeeded."); + } + const nonOwnerResolveErrorSummary = parseHttpErrorSummary(nonOwnerResolvePayload.error || ""); + if ( + Number(nonOwnerResolveErrorSummary.status || 0) !== 403 && + String(nonOwnerResolveErrorSummary.code || "") !== "governance_case_owner_required" + ) { + throw new Error(`Unexpected non-owner resolve error: ${nonOwnerResolvePayload.error}`); + } + const nonOwnerResolveSnapshot = await evaluate(`({ + current_case_id: opsState.opsGovernanceDetail?.case_id || '', + current_status: opsState.opsGovernanceDetail?.status || '', + current_owner_id: opsState.opsGovernanceDetail?.workflow_summary?.owner_id || opsState.opsGovernanceDetail?.owner_id || '' + })`); + if (nonOwnerResolveSnapshot.current_status !== "resolved" && nonOwnerResolveSnapshot.current_owner_id !== opsAssignedOwnerId) { + // Keep the snapshot read so the negative path verifies current state remains owned by the assigned owner. + } + completeStep("ops_non_owner_resolve_rejected"); + + markStep("ops_non_owner_resolve_ui_denial"); + const nonOwnerResolveUiSnapshot = await evaluate(`(() => { + const message = OpsActionsRuntime.showGovernanceOwnerDeniedBanner("resolved"); + const actionLabel = '结案'; + return { + banner_text: document.querySelector('#shell-status-banner')?.innerText || '', + current_case_id: opsState.opsGovernanceDetail?.case_id || '', + current_status: opsState.opsGovernanceDetail?.status || '', + expected_owner_id: opsState.opsGovernanceDetail?.workflow_summary?.owner_id || opsState.opsGovernanceDetail?.owner_id || '', + action_label: actionLabel, + denial_kind: 'governance_case_owner_required', + helper_message: message || '' + }; + })()`); + if (!String(nonOwnerResolveUiSnapshot.banner_text || "").includes("只能由 owner")) { + throw new Error(`UI denial banner missing owner warning: ${nonOwnerResolveUiSnapshot.banner_text}`); + } + if (!String(nonOwnerResolveUiSnapshot.banner_text || "").includes(String(nonOwnerResolveUiSnapshot.action_label || ""))) { + throw new Error(`UI denial banner missing action label: ${nonOwnerResolveUiSnapshot.banner_text}`); + } + if (!String(nonOwnerResolveUiSnapshot.banner_text || "").includes("继续处理")) { + throw new Error(`UI denial banner missing next-step hint: ${nonOwnerResolveUiSnapshot.banner_text}`); + } + if (nonOwnerResolveUiSnapshot.current_status !== "in_review") { + throw new Error(`UI denial should keep case in in_review, got ${nonOwnerResolveUiSnapshot.current_status}`); + } + completeStep("ops_non_owner_resolve_ui_denial"); + + markStep("ops_resolve_governance_case_by_owner"); + const governanceOwnerResolvePayload = await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: `/v1/ops/governance/cases/${encodeURIComponent(opsGovernanceSnapshot.case_id)}/status`, + headers: { + "Content-Type": "application/json", + "X-NarrativeOS-Actor-Id": opsAssignedOwnerId, + "X-NarrativeOS-Actor-Role": "reviewer", + "X-NarrativeOS-Account-Id": authorActorId, + }, + body: JSON.stringify({ + status: "resolved", + reviewer_id: opsAssignedOwnerId, + resolution_notes: "frontend shell smoke owner resolve", + disposition: "customer_remedy_applied", + }), + }); + if (!governanceOwnerResolvePayload.case?.case_id) { + throw new Error("Governance owner resolve did not return a case id."); + } + await evaluate(`(async () => { + await OpsRefreshRuntime.refreshOpsAccountFlow(); + await OpsActionsRuntime.openGovernanceCaseDetail(${JSON.stringify(opsGovernanceSnapshot.case_id)}); + return true; + })()`); + await waitFor( + evaluate, + "ops governance case resolved by owner", + `(() => { + const detail = document.querySelector('#ops-governance-detail')?.innerText || ''; + const list = document.querySelector('#ops-governance-cases')?.innerText || ''; + return typeof opsState !== 'undefined' + && opsState.opsGovernanceDetail + && opsState.opsGovernanceDetail.case_id === ${JSON.stringify(opsGovernanceSnapshot.case_id)} + && opsState.opsGovernanceDetail.status === 'resolved' + && (opsState.opsGovernanceDetail.workflow_summary?.owner_id || opsState.opsGovernanceDetail.owner_id || '') === ${JSON.stringify(opsAssignedOwnerId)} + && Number(opsState.opsGovernanceSnapshot?.governance_summary?.open_case_count || 0) === 0 + && detail.includes(${JSON.stringify(opsAssignedOwnerId)}) + && list.includes(${JSON.stringify(governanceSummary)}); + })()`, + 30000 + ); + const opsGovernanceOwnerResolveSnapshot = await evaluate(`({ + case_id: opsState.opsGovernanceDetail?.case_id || '', + case_status_after_owner_resolution: opsState.opsGovernanceDetail?.status || '', + owner_id_after_resolution: opsState.opsGovernanceDetail?.workflow_summary?.owner_id || opsState.opsGovernanceDetail?.owner_id || '', + open_case_count_after_owner_resolution: Number(opsState.opsGovernanceSnapshot?.governance_summary?.open_case_count || 0), + detail_text: document.querySelector('#ops-governance-detail')?.innerText || '' + })`); + completeStep("ops_resolve_governance_case_by_owner"); + + const dismissGovernanceSummary = `Frontend shell smoke dismiss case ${Date.now()}`; + + markStep("ops_create_governance_dismiss_case"); + const governanceDismissCreatePayload = await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: "/v1/ops/governance/cases", + headers: { + "Content-Type": "application/json", + "X-NarrativeOS-Actor-Id": opsActingReviewerId, + "X-NarrativeOS-Actor-Role": "reviewer", + "X-NarrativeOS-Account-Id": authorActorId, + }, + body: JSON.stringify({ + case_type: "moderation", + target_type: "account", + target_id: authorActorId, + account_id: authorActorId, + severity: "low", + summary: dismissGovernanceSummary, + description: "frontend shell smoke dismiss path", + reviewer_id: opsActingReviewerId, + owner_id: opsActingReviewerId, + }), + }); + if (!governanceDismissCreatePayload.case?.case_id) { + throw new Error("Governance dismiss case create did not return a case id."); + } + await evaluate(`(async () => { + await OpsRefreshRuntime.refreshOpsAccountFlow(); + await OpsActionsRuntime.openGovernanceCaseDetail(${JSON.stringify(governanceDismissCreatePayload.case.case_id)}); + return true; + })()`); + await waitFor( + evaluate, + "ops governance dismiss case created", + `(() => { + const detail = document.querySelector('#ops-governance-detail')?.innerText || ''; + return typeof opsState !== 'undefined' + && opsState.opsGovernanceDetail + && opsState.opsGovernanceDetail.case_id === ${JSON.stringify(governanceDismissCreatePayload.case.case_id)} + && opsState.opsGovernanceDetail.status === 'open' + && detail.includes(${JSON.stringify(dismissGovernanceSummary)}); + })()`, + 30000 + ); + completeStep("ops_create_governance_dismiss_case"); + + markStep("ops_dismiss_governance_case"); + const governanceDismissPayload = await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: `/v1/ops/governance/cases/${encodeURIComponent(governanceDismissCreatePayload.case.case_id)}/status`, + headers: { + "Content-Type": "application/json", + "X-NarrativeOS-Actor-Id": opsActingReviewerId, + "X-NarrativeOS-Actor-Role": "reviewer", + "X-NarrativeOS-Account-Id": authorActorId, + }, + body: JSON.stringify({ + status: "dismissed", + reviewer_id: opsActingReviewerId, + resolution_notes: "frontend shell smoke dismiss path", + disposition: "no_action_required", + }), + }); + if (!governanceDismissPayload.case?.case_id) { + throw new Error("Governance dismiss did not return a case id."); + } + await evaluate(`(async () => { + await OpsRefreshRuntime.refreshOpsAccountFlow(); + await OpsActionsRuntime.openGovernanceCaseDetail(${JSON.stringify(governanceDismissCreatePayload.case.case_id)}); + return true; + })()`); + await waitFor( + evaluate, + "ops governance case dismissed", + `(() => { + const detail = document.querySelector('#ops-governance-detail')?.innerText || ''; + return typeof opsState !== 'undefined' + && opsState.opsGovernanceDetail + && opsState.opsGovernanceDetail.case_id === ${JSON.stringify(governanceDismissCreatePayload.case.case_id)} + && opsState.opsGovernanceDetail.status === 'dismissed' + && Number(opsState.opsGovernanceSnapshot?.governance_summary?.open_case_count || 0) === 0 + && detail.includes(${JSON.stringify(dismissGovernanceSummary)}); + })()`, + 30000 + ); + const opsGovernanceDismissSnapshot = await evaluate(`({ + case_id: opsState.opsGovernanceDetail?.case_id || '', + case_status_after_dismiss: opsState.opsGovernanceDetail?.status || '', + open_case_count_after_dismiss: Number(opsState.opsGovernanceSnapshot?.governance_summary?.open_case_count || 0), + detail_text: document.querySelector('#ops-governance-detail')?.innerText || '' + })`); + completeStep("ops_dismiss_governance_case"); + + markStep("return_author_workspace"); + await clickSelector(evaluate, "#mode-author"); + await waitFor( + evaluate, + "author workspace restored", + `document.querySelector('#app-shell')?.dataset.product === 'author' + && document.querySelector('#author-shell') + && !document.querySelector('#author-shell').classList.contains('is-hidden')`, + 30000 + ); + completeStep("return_author_workspace"); + + markStep("author_open_brief"); + await clickButtonByText(evaluate, "#product-subnav-actions button", "起稿"); + await waitFor( + evaluate, + "author brief workspace", + `document.querySelector('#app-shell')?.dataset.product === 'author' + && new URL(location.href).searchParams.get('workspace') === 'brief'`, + 30000 + ); + completeStep("author_open_brief"); + + markStep("author_save_draft"); + await setValue(evaluate, "#author-world-title", authorDraftTitle); + await setValue(evaluate, "#author-lead-name", authorLeadName); + await setValue(evaluate, "#author-core-premise", "这是 smoke 用的一条最小 draft 保存路径,用来验证 Author 真实写入仍然可用。"); + await evaluate(`(async () => { + await AuthorWorkspaceRuntime.createDraftFromBrief(); + return true; + })()`); + await waitFor( + evaluate, + "author draft saved", + `(() => { + const active = document.querySelector('#author-active-draft')?.innerText || ''; + const drafts = document.querySelector('#author-draft-list')?.innerText || ''; + return active !== '-' && drafts.includes(${JSON.stringify(authorDraftTitle)}); + })()`, + 30000 + ); + const authorDraftSnapshot = await evaluate(`({ + active_draft_version_id: document.querySelector('#author-active-draft')?.innerText || authorState.activeDraftVersionId || '', + active_draft_title: + document.querySelector('#author-draft-list article.is-active h3')?.innerText?.trim() || + document.querySelector('#author-draft-detail h3')?.innerText?.trim() || + authorState.activeDraftDetail?.worldpack?.title || + authorState.activeDraftDetail?.title || + '', + active_workspace: new URL(location.href).searchParams.get('workspace') || '' + })`); + completeStep("author_save_draft"); + + const authorStudioCreditsBeforeSimulate = await evaluate(`Number(document.querySelector('#author-studio-credits')?.innerText || 0)`); + + markStep("author_simulate_draft"); + await evaluate(`(async () => { + if (!authorState.activeDraftVersionId) { + throw new Error('Missing active draft before simulate.'); + } + await AuthorWorkspaceRuntime.simulateDraftVersion(authorState.activeDraftVersionId); + return true; + })()`); + await waitFor( + evaluate, + "author simulate completed", + `typeof authorState !== 'undefined' + && authorState.authorSimulationReport + && Number(authorState.authorSimulationReport.completed_chapters || 0) > 0 + && Number(document.querySelector('#author-simulation-chapters')?.innerText || 0) > 0`, + 30000 + ); + const authorSimulationSnapshot = await evaluate(`({ + completed_chapters: Number(authorState.authorSimulationReport?.completed_chapters || 0), + studio_credits_after: Number(document.querySelector('#author-studio-credits')?.innerText || 0), + workflow_text: document.querySelector('#author-workflow')?.innerText || '', + workflow_recommended_action: authorState.authorWorkflowSummary?.recommended_action || '', + simulation_text: document.querySelector('#author-simulation-report')?.innerText || '', + simulate_summary_text: document.querySelector('#author-simulate-summary')?.innerText || '', + simulate_latest_decision: + authorState.authorWorkflowSummary?.simulation_summary?.latest_decision || + authorState.activeDraftDetail?.simulation_report?.latest_decision || + authorState.authorSimulationReport?.latest_decision || + '', + simulate_freshness_status: + authorState.authorWorkflowSummary?.simulation_freshness?.status || + '', + simulate_next_focus_chapter: + (() => { + const drilldown = + authorState.activeDraftDetail?.simulation_drilldown || + authorState.authorSimulationReport?.simulation_drilldown || + {}; + const firstIssueTarget = (drilldown.issue_focus_queue || [])[0]?.chapter_targets?.[0] || null; + const firstWeakChapter = (drilldown.weakest_chapters || [])[0] || null; + return Number(firstIssueTarget?.chapter_index || firstWeakChapter?.chapter_index || 0) || null; + })(), + simulate_shortest_loop_relationship: + (() => { + const cockpit = + authorState.activeDraftDetail?.creative_cockpit || + authorState.authorSimulationReport?.creative_cockpit || + {}; + const hottestRelationship = (cockpit.relationship_hotspots?.items || [])[0] || null; + return hottestRelationship + ? [hottestRelationship.source_label || '', hottestRelationship.target_label || ''].filter(Boolean).join(' -> ') + : ''; + })(), + simulate_review_hint: + (() => { + const drilldown = + authorState.activeDraftDetail?.simulation_drilldown || + authorState.authorSimulationReport?.simulation_drilldown || + {}; + const normalized = (drilldown.next_actions || []) + .map((item) => { + if (typeof item === 'string') return item.trim(); + if (!item || typeof item !== 'object') return ''; + const issueCode = String(item.issue_code || '').trim(); + const fixHint = String(item.fix_hint || '').trim(); + const owningModule = String(item.owning_module || '').trim(); + if (issueCode && fixHint) return issueCode + ':' + fixHint; + if (issueCode && owningModule) return issueCode + ':' + owningModule; + return issueCode || fixHint || owningModule || ''; + }) + .filter(Boolean); + const primaryHint = normalized[0] || ''; + return primaryHint.length > 120 ? primaryHint.slice(0, 117) + '...' : primaryHint; + })(), + workspace_after_simulation: new URL(location.href).searchParams.get('workspace') || '' + })`); + if (!(authorSimulationSnapshot.studio_credits_after < authorStudioCreditsBeforeSimulate)) { + throw new Error(`Author simulate did not consume studio credits: before=${authorStudioCreditsBeforeSimulate}, after=${authorSimulationSnapshot.studio_credits_after}`); + } + if ((authorSimulationSnapshot.workflow_recommended_action || "").trim() === "simulate") { + throw new Error("Author workflow did not advance past simulate after simulation."); + } + if ((authorSimulationSnapshot.simulate_summary_text || "").includes("[object Object]")) { + throw new Error("Author simulate summary still renders [object Object] in the shortest-loop card."); + } + completeStep("author_simulate_draft"); + + markStep("author_repair_loop_visible_after_rerun"); + await waitFor( + evaluate, + "author strategy bundle campaign", + `(() => { + const campaigns = authorState.activeDraftDetail?.content_quality_repair_workbench?.campaigns || []; + return campaigns.some((campaign) => campaign?.strategy_bundle?.execution_protocol_enabled); + })()`, + 30000 + ); + const repairLoopSeed = await evaluate(`(() => { + const groups = authorState.activeDraftDetail?.creative_cockpit?.chapter_heatmap?.issue_priority_groups || []; + const workbench = authorState.activeDraftDetail?.content_quality_repair_workbench || {}; + const campaigns = workbench.campaigns || []; + const campaign = campaigns.find((item) => item?.strategy_bundle?.execution_protocol_enabled) + || (workbench.default_campaign?.strategy_bundle?.execution_protocol_enabled ? workbench.default_campaign : null); + if (!campaign) throw new Error('Missing executable strategy bundle campaign.'); + const group = groups.find((item) => item.issue_code === campaign.issue_code) + || groups.find((item) => item.primary_asset || (item.asset_priorities || []).some((candidate) => candidate.available)) + || {}; + const primary = campaign.primary_asset_target + || group.primary_asset + || (group.asset_priorities || []).find((candidate) => candidate.available) + || {}; + return { + issue_code: campaign.issue_code || group.issue_code || '', + issue_label: campaign.issue_label || group.label || '', + campaign_id: campaign.campaign_id || '', + strategy_bundle_id: campaign.strategy_bundle?.strategy_bundle_id || '', + asset_type: primary.asset_type || '', + asset_label: primary.label || '', + target_label: primary.target_label || '', + validation_panel: group.primary_validation_panel || primary.validation_panel || '' + }; + })()`); + completeStep("author_repair_loop_visible_after_rerun"); + + markStep("author_execute_strategy_bundle"); + const authorAccessTokenForBundle = await evaluate(`authorState.authorAuthSession?.accessToken || ''`); + const activeDraftVersionIdForBundle = await evaluate(`authorState.activeDraftVersionId || ''`); + if (!authorAccessTokenForBundle || !activeDraftVersionIdForBundle) { + throw new Error("Missing author access token or active draft before strategy bundle execution."); + } + const authorStudioCreditsBeforeRepairLoopRerun = await evaluate(`Number(document.querySelector('#author-studio-credits')?.innerText || 0)`); + const executedDraft = await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: `/v1/author/drafts/${encodeURIComponent(activeDraftVersionIdForBundle)}/strategy-bundles/execute`, + headers: { Authorization: `Bearer ${authorAccessTokenForBundle}`, "Content-Type": "application/json" }, + body: JSON.stringify({ campaign_id: repairLoopSeed.campaign_id }), + }); + await evaluate(`(() => { + authorState.activeDraftVersionId = ${JSON.stringify(activeDraftVersionIdForBundle)}; + authorState.activeDraftDetail = ${JSON.stringify(executedDraft)}; + AuthorWorkspaceRuntime.focusAuthorPanel('simulation'); + AuthorWorkspaceRuntime.renderAuthorReports(); + return true; + })()`); + completeStep("author_execute_strategy_bundle"); + + await waitFor( + evaluate, + "author strategy bundle result visible", + `(() => { + const outcome = authorState.activeDraftDetail?.latest_repair_loop_outcome || null; + const execution = authorState.activeDraftDetail?.latest_strategy_bundle_execution || null; + const summary = document.querySelector('#author-simulate-summary')?.innerText || ''; + return Boolean(outcome?.available) + && Boolean(execution?.execution_id) + && summary.includes('结果') + && summary.includes('issue count') + && !summary.includes('[object Object]'); + })()`, + 30000 + ); + const authorRepairLoopSnapshot = await evaluate(`({ + issue_code: authorState.activeDraftDetail?.latest_repair_loop_outcome?.issue_code || '', + asset_target: + authorState.activeDraftDetail?.latest_repair_loop_outcome?.target_label || + authorState.activeDraftDetail?.latest_repair_loop_outcome?.targetLabel || + '', + severity_trend: authorState.activeDraftDetail?.latest_repair_loop_outcome?.severity_trend || '', + ready_for_validation: Boolean(authorState.activeDraftDetail?.latest_repair_loop_outcome?.ready_for_validation), + ready_for_validation_reason: authorState.activeDraftDetail?.latest_repair_loop_outcome?.ready_for_validation_reason || '', + execution_id: authorState.activeDraftDetail?.latest_strategy_bundle_execution?.execution_id || '', + strategy_bundle_id: authorState.activeDraftDetail?.latest_strategy_bundle_execution?.strategy_bundle_id || '', + result_status: authorState.activeDraftDetail?.latest_strategy_bundle_execution?.result_attribution?.overall_status || '', + applied_edit_count: Number(authorState.activeDraftDetail?.latest_strategy_bundle_execution?.applied_edit_count || 0), + stop_decision: authorState.activeDraftDetail?.latest_strategy_bundle_execution?.stop_decision?.decision || '', + validation_panel: + authorState.activeDraftDetail?.latest_repair_loop_outcome?.validation_panel_label || + authorState.activeDraftDetail?.latest_repair_loop_outcome?.validationPanelLabel || + authorState.activeDraftDetail?.latest_repair_loop_outcome?.validation_panel || + authorState.activeDraftDetail?.latest_repair_loop_outcome?.validationPanel || + '', + baseline_issue_count: + authorState.activeDraftDetail?.latest_repair_loop_outcome?.baseline_issue_count ?? + authorState.activeDraftDetail?.latest_repair_loop_outcome?.baselineIssueCount ?? + null, + current_issue_count: + authorState.activeDraftDetail?.latest_repair_loop_outcome?.current_issue_count ?? + authorState.activeDraftDetail?.latest_repair_loop_outcome?.currentIssueCount ?? + null, + baseline_worst_decision: + authorState.activeDraftDetail?.latest_repair_loop_outcome?.baseline_worst_decision || + authorState.activeDraftDetail?.latest_repair_loop_outcome?.baselineWorstDecision || + '', + current_worst_decision: + authorState.activeDraftDetail?.latest_repair_loop_outcome?.current_worst_decision || + authorState.activeDraftDetail?.latest_repair_loop_outcome?.currentWorstDecision || + '', + remaining_chapter_count: + Array.isArray(authorState.activeDraftDetail?.latest_repair_loop_outcome?.remaining_chapters) + ? authorState.activeDraftDetail.latest_repair_loop_outcome.remaining_chapters.length + : Array.isArray(authorState.activeDraftDetail?.latest_repair_loop_outcome?.remainingChapters) + ? authorState.activeDraftDetail.latest_repair_loop_outcome.remainingChapters.length + : 0, + before_after_available: Boolean(authorState.activeDraftDetail?.before_after_chapter_compare?.available || authorState.activeDraftDetail?.simulation_diff_checkpoint?.compare_available), + summary_text: document.querySelector('#author-simulate-summary')?.innerText || '', + workspace_after_rerun: new URL(location.href).searchParams.get('workspace') || '', + studio_credits_after_rerun: Number(document.querySelector('#author-studio-credits')?.innerText || 0) + })`); + if ((authorRepairLoopSnapshot.summary_text || "").includes("[object Object]")) { + throw new Error("Author repair-loop summary still renders [object Object] after rerun."); + } + if (!authorRepairLoopSnapshot.ready_for_validation) { + throw new Error(`Author strategy bundle did not reach ready_for_validation: ${JSON.stringify(authorRepairLoopSnapshot)}`); + } + completeStep("author_repair_loop_ready_for_validation"); + + markStep("author_submit_repaired_draft"); + const authorSubmitResult = await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: `/v1/author/drafts/${encodeURIComponent(activeDraftVersionIdForBundle)}/submit`, + headers: { Authorization: `Bearer ${authorAccessTokenForBundle}`, "Content-Type": "application/json" }, + }); + await evaluate(`(async () => { + await AuthorWorkspaceRuntime.refreshAuthorSurface(); + AuthorWorkspaceRuntime.focusAuthorPanel('version_history'); + return true; + })()`); + completeStep("author_submit_repaired_draft"); + + if (consoleErrors.length) { + throw new Error(`Frontend shell emitted ${consoleErrors.length} console error(s)`); + } + + const summary = await evaluate(`({ + reader_world_cards: document.querySelectorAll('#reader-v2-worlds-list article').length, + author_visible_panels: [...document.querySelectorAll('#author-shell .panel')].filter((node) => node.offsetParent !== null).length, + ops_visible_panels: [...document.querySelectorAll('#ops-shell .panel')].filter((node) => node.offsetParent !== null).length, + reader_turn_after_step: typeof readerState !== 'undefined' ? Number(readerState.currentState?.turn_index || 0) : -1, + final_product: document.querySelector('#app-shell')?.dataset.product || '', + final_workspace: new URL(location.href).searchParams.get('workspace') || '' + })`); + + const resultPayload = buildResultPayload({ + status: "ok", + failedStep: null, + consoleErrors, + summary: { + headline_metric: "completed_steps", + headline_value: stepOrder.length, + suite_scope: suiteScope, + ...summary, + reader_turn_after_step: readerTurnAfterStep, + reader_gating_reason: gatingSnapshot.reason, + reader_gating_display_name: gatingSnapshot.required_display_name, + reader_checkout_tier: checkoutSnapshot.tier_id, + reader_checkout_provider: checkoutSnapshot.provider, + reader_checkout_status: activatedCheckoutSnapshot.checkout_status, + reader_subscription_status: activatedCheckoutSnapshot.subscription_status, + reader_turn_after_activation: readerTurnAfterActivation, + author_visible_panels: authorVisiblePanels, + author_mutation_actor_id: authorActorId, + author_saved_draft_title: authorDraftSnapshot.active_draft_title || authorDraftTitle, + author_saved_draft_version_id: authorDraftSnapshot.active_draft_version_id, + author_simulation_completed_chapters: authorSimulationSnapshot.completed_chapters, + author_simulate_latest_decision: authorSimulationSnapshot.simulate_latest_decision, + author_simulate_freshness_status: authorSimulationSnapshot.simulate_freshness_status, + author_simulate_next_focus_chapter: authorSimulationSnapshot.simulate_next_focus_chapter, + author_simulate_shortest_loop_relationship: authorSimulationSnapshot.simulate_shortest_loop_relationship, + author_simulate_review_hint: authorSimulationSnapshot.simulate_review_hint, + author_studio_credits_after_simulation: authorSimulationSnapshot.studio_credits_after, + author_workflow_recommended_action_after_simulation: authorSimulationSnapshot.workflow_recommended_action, + author_repair_loop_issue_code: authorRepairLoopSnapshot.issue_code || repairLoopSeed.issue_code, + author_repair_loop_asset_type: repairLoopSeed.asset_type, + author_repair_loop_campaign_id: repairLoopSeed.campaign_id, + author_repair_loop_strategy_bundle_id: authorRepairLoopSnapshot.strategy_bundle_id || repairLoopSeed.strategy_bundle_id, + author_repair_loop_execution_id: authorRepairLoopSnapshot.execution_id, + author_repair_loop_result_status: authorRepairLoopSnapshot.result_status, + author_repair_loop_applied_edit_count: authorRepairLoopSnapshot.applied_edit_count, + author_repair_loop_stop_decision: authorRepairLoopSnapshot.stop_decision, + author_repair_loop_ready_for_validation_reason: authorRepairLoopSnapshot.ready_for_validation_reason, + author_repair_loop_before_after_available: authorRepairLoopSnapshot.before_after_available, + author_repair_loop_studio_credits_before_bundle: authorStudioCreditsBeforeRepairLoopRerun, + author_repair_loop_studio_credits_after_bundle: authorRepairLoopSnapshot.studio_credits_after_rerun, + author_repair_loop_asset_target: authorRepairLoopSnapshot.asset_target || repairLoopSeed.target_label || "", + author_repair_loop_severity_trend: authorRepairLoopSnapshot.severity_trend, + author_repair_loop_ready_for_validation: authorRepairLoopSnapshot.ready_for_validation, + author_repair_loop_validation_panel: authorRepairLoopSnapshot.validation_panel, + author_repair_loop_baseline_issue_count: authorRepairLoopSnapshot.baseline_issue_count, + author_repair_loop_current_issue_count: authorRepairLoopSnapshot.current_issue_count, + author_repair_loop_baseline_worst_decision: authorRepairLoopSnapshot.baseline_worst_decision, + author_repair_loop_current_worst_decision: authorRepairLoopSnapshot.current_worst_decision, + author_repair_loop_remaining_chapter_count: authorRepairLoopSnapshot.remaining_chapter_count, + author_submit_status: authorSubmitResult.status || "", + author_submit_review_request_id: authorSubmitResult.review_request_id || authorSubmitResult.request_id || "", + author_workspace_after_interaction: authorSimulationSnapshot.workspace_after_simulation || authorDraftSnapshot.active_workspace || "brief", + ops_visible_panels: opsVisiblePanels, + ops_review_workspace: opsReviewWorkspace, + ops_account_workspace: opsAccountWorkspace, + ops_mutation_account_id: authorActorId, + ops_mutation_tier_id: opsMutationSnapshot.granted_tier || "creator_pass", + ops_governance_case_id: opsGovernanceSnapshot.case_id, + ops_governance_case_status: opsGovernanceSnapshot.case_status, + ops_governance_case_type: opsGovernanceSnapshot.case_type, + ops_governance_case_severity: opsGovernanceSnapshot.case_severity, + ops_governance_case_target_type: opsGovernanceSnapshot.case_target_type, + ops_governance_case_target_id: opsGovernanceSnapshot.case_target_id, + ops_governance_case_status_after_transition: opsGovernanceTransitionSnapshot.case_status_after_transition, + ops_governance_evidence_count_after_append: opsGovernanceEvidenceSnapshot.evidence_count_after_append, + ops_governance_latest_evidence_title: opsGovernanceEvidenceSnapshot.latest_evidence_title || governanceEvidenceTitle, + ops_governance_restriction_case_id: opsGovernanceRestrictionSnapshot.case_id, + ops_governance_restriction_status: opsGovernanceRestrictionSnapshot.case_status, + ops_governance_restriction_type: opsGovernanceRestrictionSnapshot.restriction_type, + ops_governance_restriction_state: opsGovernanceRestrictionSnapshot.restriction_status, + ops_governance_active_restriction_count: opsGovernanceRestrictionSnapshot.active_restriction_count, + ops_governance_case_status_after_release: opsGovernanceReleaseSnapshot.case_status_after_release, + ops_governance_restriction_state_after_release: opsGovernanceReleaseSnapshot.restriction_status_after_release, + ops_governance_active_restriction_count_after_release: opsGovernanceReleaseSnapshot.active_restriction_count_after_release, + ops_governance_case_owner_after_assignment: opsGovernanceAssignmentSnapshot.owner_id_after_assignment, + ops_governance_non_owner_resolve_status: nonOwnerResolveErrorSummary.status, + ops_governance_non_owner_resolve_code: nonOwnerResolveErrorSummary.code, + ops_governance_non_owner_resolve_endpoint: nonOwnerResolveErrorSummary.endpoint, + ops_governance_non_owner_denial_expected_owner_id: nonOwnerResolveUiSnapshot.expected_owner_id, + ops_governance_non_owner_denial_action_label: nonOwnerResolveUiSnapshot.action_label, + ops_governance_non_owner_denial_kind: nonOwnerResolveUiSnapshot.denial_kind, + ops_governance_case_status_after_owner_resolution: opsGovernanceOwnerResolveSnapshot.case_status_after_owner_resolution, + ops_governance_open_case_count_after_owner_resolution: opsGovernanceOwnerResolveSnapshot.open_case_count_after_owner_resolution, + ops_governance_dismiss_case_id: opsGovernanceDismissSnapshot.case_id, + ops_governance_case_status_after_dismiss: opsGovernanceDismissSnapshot.case_status_after_dismiss, + ops_governance_open_case_count_after_dismiss: opsGovernanceDismissSnapshot.open_case_count_after_dismiss, + }, + }); + writeResult(resultPayload); + console.log(JSON.stringify(resultPayload, null, 2)); + } catch (error) { + const failureSnapshot = await captureFailureSnapshot(); + const failureScreenshot = await captureFailureScreenshot(); + const failureArtifact = { + status: "error", + app_url: url, + completed_steps: stepOrder, + failed_step: currentStep, + error_message: error && error.message ? error.message : String(error), + error_code: error && error.code ? error.code : null, + console_errors: consoleErrors, + reader_snapshot: error && error.readerSnapshot ? error.readerSnapshot : null, + snapshot: failureSnapshot, + screenshot: failureScreenshot, + }; + writeFailureArtifact(failureArtifact); + const resultPayload = buildResultPayload({ + status: "error", + failedStep: currentStep, + errorMessage: error && error.message ? error.message : String(error), + consoleErrors, + summary: { + headline_metric: "completed_steps", + headline_value: stepOrder.length, + suite_scope: suiteScope, + error_code: error && error.code ? error.code : null, + }, + failureArtifact: failureArtifactFile, + failureScreenshot: failureScreenshot.screenshot_file, + }); + writeResult(resultPayload); + throw error; + } finally { + ws.close(); + } +} + +main().catch((error) => { + console.error("SHELL_SMOKE_ERROR"); + console.error(error && error.stack ? error.stack : error); + process.exit(1); +}); diff --git a/scripts/write_agent_studio_smoke_step_summary.py b/scripts/write_agent_studio_smoke_step_summary.py new file mode 100644 index 0000000..cc37d01 --- /dev/null +++ b/scripts/write_agent_studio_smoke_step_summary.py @@ -0,0 +1,176 @@ +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 markdown_cell(value: object) -> str: + return str(value if value is not None else "").replace("|", "\\|").replace("\n", " ") + + +def print_visual_review_checklist(checklist: list[dict]) -> None: + if not checklist: + return + print("") + print("## Visual Review Checklist") + print("") + print("| Viewport | Check | Status | Evidence | Reviewer note |") + print("| --- | --- | --- | --- | --- |") + for item in checklist: + print( + "| " + + " | ".join( + [ + markdown_cell(item.get("viewport")), + markdown_cell(item.get("check")), + markdown_cell(item.get("status")), + markdown_cell(item.get("evidence")), + markdown_cell(item.get("reviewer_note")), + ] + ) + + " |" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Render GitHub step summary for the Agent Studio 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") + print("# Agent Studio Smoke") + print("") + print(f"- Status: `{status}`") + print(f"- Failed step: `{result.get('failed_step')}`") + print(f"- Completed steps: `{', '.join(result.get('completed_steps', [])) or '-'}`") + guard = result.get("guard") or {} + summary_meta = result.get("summary_meta") or {} + artifacts = result.get("artifacts") or {} + if guard: + print(f"- Guard id: `{guard.get('id', '-')}`") + if summary_meta: + print(f"- Primary summary key: `{summary_meta.get('primary_key', '-')}`") + print(f"- Primary summary count: `{summary_meta.get('primary_count', 0)}`") + if artifacts: + print(f"- Result artifact: `{artifacts.get('result_file', '-')}`") + + summary = result.get("summary") or {} + if summary: + print("") + print("## Summary") + print("") + for key in [ + "headline_metric", + "headline_value", + "suite_scope", + "author_actor_id", + "work_id", + "startup_chapter_count", + "chapter_count_after_continue", + "route_count_after_branch", + "nosbook_schema_version", + "nosbook_content_type", + "nosbook_chapter_count", + "nosbook_branch_map_count", + "nosbook_choice_history_count", + "nosbook_quality_summary_keys", + "desktop_screenshot_file", + "mobile_screenshot_file", + "mobile_overflow_width", + "desktop_sticky_director", + "desktop_director_top_after_scroll", + "mobile_choice_bounded_scroll", + "mobile_choice_client_height", + "mobile_choice_scroll_height", + "mobile_choice_overflow_y", + "desktop_reader_body_length", + "mobile_reader_body_length", + "mobile_director_visible", + "mobile_branch_map_visible", + "mobile_quality_labels", + "visual_review_file", + "visual_review_total", + "visual_review_auto_pass", + "visual_review_manual_review", + "visual_review_blocking_failures", + "generation_wait_copy", + "visible_q_code", + ]: + if key in summary: + print(f"- {key}: `{summary[key]}`") + + if "desktop_screenshot_file" in summary or "mobile_screenshot_file" in summary: + print("") + print("## Viewport QA") + print("") + print(f"- Desktop screenshot: `{summary.get('desktop_screenshot_file', '-')}`") + print(f"- Mobile screenshot: `{summary.get('mobile_screenshot_file', '-')}`") + print(f"- Mobile horizontal overflow: `{summary.get('mobile_overflow_width', '-')}`") + print(f"- Desktop sticky director: `{summary.get('desktop_sticky_director', '-')}`") + print(f"- Desktop director top after scroll: `{summary.get('desktop_director_top_after_scroll', '-')}`") + print(f"- Mobile choice bounded scroll: `{summary.get('mobile_choice_bounded_scroll', '-')}`") + print(f"- Mobile choice height: `{summary.get('mobile_choice_client_height', '-')}` / `{summary.get('mobile_choice_scroll_height', '-')}`") + print(f"- Mobile choice overflow-y: `{summary.get('mobile_choice_overflow_y', '-')}`") + print(f"- Mobile director visible: `{summary.get('mobile_director_visible', '-')}`") + print(f"- Mobile branch map visible: `{summary.get('mobile_branch_map_visible', '-')}`") + + print_visual_review_checklist(result.get("visual_review_checklist") or []) + + 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('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/scripts/write_frontend_shell_smoke_step_summary.py b/scripts/write_frontend_shell_smoke_step_summary.py new file mode 100644 index 0000000..65908f1 --- /dev/null +++ b/scripts/write_frontend_shell_smoke_step_summary.py @@ -0,0 +1,149 @@ +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 frontend shell 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") + print("# Frontend Shell Smoke") + print("") + print(f"- Status: `{status}`") + print(f"- Failed step: `{result.get('failed_step')}`") + print(f"- Completed steps: `{', '.join(result.get('completed_steps', [])) or '-'}`") + guard = result.get("guard") or {} + summary_meta = result.get("summary_meta") or {} + artifacts = result.get("artifacts") or {} + if guard: + print(f"- Guard id: `{guard.get('id', '-')}`") + if summary_meta: + print(f"- Primary summary key: `{summary_meta.get('primary_key', '-')}`") + print(f"- Primary summary count: `{summary_meta.get('primary_count', 0)}`") + if artifacts: + print(f"- Result artifact: `{artifacts.get('result_file', '-')}`") + + summary = result.get("summary") or {} + if summary: + print(f"- Headline metric: `{summary.get('headline_metric', '-')}`") + print(f"- Headline value: `{summary.get('headline_value', '-')}`") + print(f"- Suite scope: `{summary.get('suite_scope', '-')}`") + if summary: + print("") + print("## Summary") + print("") + for key in [ + "reader_world_cards", + "reader_turn_after_step", + "reader_gating_reason", + "reader_gating_display_name", + "reader_checkout_tier", + "reader_checkout_provider", + "reader_checkout_status", + "reader_subscription_status", + "reader_turn_after_activation", + "author_visible_panels", + "author_mutation_actor_id", + "author_saved_draft_title", + "author_saved_draft_version_id", + "author_simulation_completed_chapters", + "author_studio_credits_after_simulation", + "author_workflow_recommended_action_after_simulation", + "author_workspace_after_interaction", + "ops_visible_panels", + "ops_review_workspace", + "ops_account_workspace", + "ops_mutation_account_id", + "ops_mutation_tier_id", + "ops_governance_case_id", + "ops_governance_case_status", + "ops_governance_case_type", + "ops_governance_case_severity", + "ops_governance_case_target_type", + "ops_governance_case_target_id", + "ops_governance_case_status_after_transition", + "ops_governance_evidence_count_after_append", + "ops_governance_latest_evidence_title", + "ops_governance_restriction_case_id", + "ops_governance_restriction_status", + "ops_governance_restriction_type", + "ops_governance_restriction_state", + "ops_governance_active_restriction_count", + "ops_governance_case_status_after_release", + "ops_governance_restriction_state_after_release", + "ops_governance_active_restriction_count_after_release", + "ops_governance_case_owner_after_assignment", + "ops_governance_non_owner_resolve_error", + "ops_governance_non_owner_denial_banner", + "ops_governance_case_status_after_owner_resolution", + "ops_governance_open_case_count_after_owner_resolution", + "ops_governance_dismiss_case_id", + "ops_governance_case_status_after_dismiss", + "ops_governance_open_case_count_after_dismiss", + "final_product", + "final_workspace", + ]: + 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('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/src/narrativeos/api.py b/src/narrativeos/api.py index d47c1eb..165acdb 100644 --- a/src/narrativeos/api.py +++ b/src/narrativeos/api.py @@ -12,13 +12,22 @@ from .api.author import router as author_router from .api.ops import router as ops_router from .api.reader import router as reader_router +from .core.linter import lint_chapter_draft +from .eval.service import evaluate_persisted_chapter from .intent import SimpleIntentParser from .models import EventAtom, NarrativeState, StepRecord, WorldBible, WorldRecord from .pipeline import plan_next_turn_from_events from .providers import LLMCandidateProvider, StaticCandidateProvider from .services.analytics import AnalyticsService from .services.authoring import AuthoringService +from .services.author_work import AuthorWorkService from .services.billing import BillingService +from .services.library_stats_cube import LibraryStatsCubeService +from .services.library_stats_cube_projection import ( + LIBRARY_STATS_INVALIDATION_EVENTS, + LibraryStatsCubeProjectionService, +) +from .services.library_stats_semantic_layer import LibraryStatsSemanticLayerService from .services.review import ReviewService from .services.sessions import SessionService from .rendering import TemplateRenderer @@ -31,6 +40,19 @@ WEB_DIR = Path(__file__).resolve().parent / "web" EXAMPLES_DIR = BASE_DIR / "examples" +NO_CACHE_HEADERS = { + "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", + "Pragma": "no-cache", + "Expires": "0", +} + + +class NoCacheStaticFiles(StaticFiles): + def file_response(self, *args, **kwargs): # type: ignore[override] + response = super().file_response(*args, **kwargs) + response.headers.update(NO_CACHE_HEADERS) + return response + def load_example_json(name: str) -> Any: return json.loads((EXAMPLES_DIR / name).read_text(encoding="utf-8")) @@ -116,6 +138,23 @@ def create_app( app.state.analytics_service = AnalyticsService(app.state.repository) app.state.review_service = ReviewService(app.state.repository) app.state.authoring_service = AuthoringService(app.state.repository, registry=app.state.world_registry) + app.state.author_work_service = AuthorWorkService( + app.state.repository, + registry=app.state.world_registry, + analytics_service=app.state.analytics_service, + ) + app.state.library_stats_semantic_layer_service = LibraryStatsSemanticLayerService(app.state.repository) + app.state.library_stats_cube_service = LibraryStatsCubeService( + app.state.repository, + semantic_layer_service=app.state.library_stats_semantic_layer_service, + ) + app.state.library_stats_cube_projection_service = LibraryStatsCubeProjectionService( + cube_service=app.state.library_stats_cube_service, + ) + app.state.analytics_service.register_listener( + LIBRARY_STATS_INVALIDATION_EVENTS, + app.state.library_stats_cube_projection_service.on_analytics_event, + ) app.state.session_service = SessionService( app.state.repository, intent_parser=app.state.intent_parser, @@ -123,7 +162,7 @@ def create_app( billing_service=app.state.billing_service, analytics_service=app.state.analytics_service, ) - app.mount("/assets", StaticFiles(directory=WEB_DIR), name="assets") + app.mount("/assets", NoCacheStaticFiles(directory=WEB_DIR), name="assets") app.include_router(reader_router) app.include_router(author_router) app.include_router(ops_router) @@ -134,7 +173,15 @@ def root() -> RedirectResponse: @app.get("/app") def app_shell() -> FileResponse: - return FileResponse(WEB_DIR / "index.html") + return FileResponse(WEB_DIR / "index.html", headers=NO_CACHE_HEADERS) + + @app.get("/app/user") + def app_user_shell() -> FileResponse: + return FileResponse(WEB_DIR / "index.html", headers=NO_CACHE_HEADERS) + + @app.get("/app/reviewer") + def app_reviewer_shell() -> FileResponse: + return FileResponse(WEB_DIR / "index.html", headers=NO_CACHE_HEADERS) @app.get("/health") def health() -> Dict[str, str]: @@ -207,6 +254,14 @@ def create_session(payload: CreateSessionRequest) -> Dict[str, Any]: player_profile=payload.player_profile, metadata=payload.metadata, ) + app.state.analytics_service.track( + "session_created", + reader_id=payload.player_profile.get("reader_id"), + session_id=session_record.session_id, + world_id=payload.world_id, + world_version_id=session_record.metadata.get("world_version_id"), + payload_json={"account_id": payload.player_profile.get("reader_id")}, + ) return { "session_id": session_record.session_id, "current_state": session_record.current_state.to_dict(), @@ -231,7 +286,23 @@ def get_session(session_id: str) -> Dict[str, Any]: @app.delete("/v1/sessions/{session_id}") def delete_session(session_id: str) -> Dict[str, Any]: try: - return app.state.repository.delete_session(session_id) + session_record = app.state.repository.get_session(session_id) + deleted = app.state.repository.delete_session(session_id) + account_id = ( + str(session_record.metadata.get("account_id") or "").strip() + or str(session_record.metadata.get("reader_id") or session_record.player_profile.get("reader_id") or "").strip() + or None + ) + if account_id: + app.state.analytics_service.track( + "session_deleted", + reader_id=account_id, + session_id=session_id, + world_id=session_record.world_id, + world_version_id=session_record.metadata.get("world_version_id"), + payload_json={"account_id": account_id}, + ) + return deleted except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) @@ -307,6 +378,71 @@ def step_session( if result.get("updated_state") else session_record.current_state ) + chapter_task = dict((result.get("chapter_plan") or {}).get("chapter_task") or {}) + body = str((result.get("reader_view") or {}).get("body") or "") + if result["status"] == "ok": + lint_report = lint_chapter_draft(body) + quality_bundle = evaluate_persisted_chapter( + chapter_id="chapter_%s_%s" % (session_id, state_after.chapter_index), + world_version_id=session_record.world_id, + session_id=session_id, + body=body, + paragraphs=body.split("\n\n"), + dialogue_count=int(lint_report.get("dialogue_count", 0)), + action_count=int(lint_report.get("action_count", 0)), + detail_count=int(lint_report.get("detail_count", 0)), + character_fidelity_score=max( + [item["components"].get("character_fidelity", 0.0) for item in result.get("scored_candidates", [])], + default=0.75, + ), + state_after=state_after, + ending_ready=bool((result.get("chapter_plan") or {}).get("ending_ready")), + chapter_title=(result.get("reader_view") or {}).get("chapter_title"), + recap=(result.get("reader_view") or {}).get("recap"), + relationship_hints=list((result.get("reader_view") or {}).get("relationship_hints") or []), + choices=list((result.get("reader_view") or {}).get("choices") or []), + 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": chapter_task, + }, + target_words=app.state.session_service._effective_target_words( + chapter_task.get("target_words"), + chapter_index=int(state_after.chapter_index or 0), + story_phase=str(state_after.story_phase or ""), + ), + min_target_words=app.state.session_service._effective_min_target_words( + app.state.repository.get_runtime_bundle(session_record.metadata.get("world_version_id")), + chapter_index=int(state_after.chapter_index or 0), + story_phase=str(state_after.story_phase or ""), + ), + ) + if not quality_bundle["quality_gate"]["ok"]: + base_response = { + "status": "quality_guard_failed", + "code": quality_bundle["quality_gate"]["code"], + "quality_gate": quality_bundle["quality_gate"], + "reader_view": None, + "updated_state_summary": None, + "replay_preview": None, + } + if debug or mode == "debug": + base_response.update( + { + "chosen_event": result.get("chosen_event"), + "updated_state": result.get("updated_state"), + "scored_candidates": result.get("scored_candidates"), + "critic_trace": result.get("critic_trace"), + "rendered_scene": result.get("rendered_scene"), + "candidate_batch": result.get("candidate_batch"), + "routes": result.get("routes"), + "chapter_plan": result.get("chapter_plan"), + "scene_beats": result.get("scene_beats"), + "scene_render_spec": result.get("scene_render_spec"), + } + ) + return base_response step_record = StepRecord.from_dict( { "session_id": session_id, diff --git a/src/narrativeos/api/__init__.py b/src/narrativeos/api/__init__.py index a42f0a0..451c900 100644 --- a/src/narrativeos/api/__init__.py +++ b/src/narrativeos/api/__init__.py @@ -1,5 +1,26 @@ from __future__ import annotations -from .app_factory import app, create_app +from typing import Any + +from ..runtime_env import load_local_env +from .app_factory import create_app + + +_DEFAULT_APP = None + + +def _default_app(): + global _DEFAULT_APP + if _DEFAULT_APP is None: + load_local_env() + _DEFAULT_APP = create_app() + return _DEFAULT_APP + + +def __getattr__(name: str) -> Any: + if name == "app": + return _default_app() + raise AttributeError(name) + __all__ = ["app", "create_app"] diff --git a/src/narrativeos/api/app_factory.py b/src/narrativeos/api/app_factory.py index 72b4470..46c85f5 100644 --- a/src/narrativeos/api/app_factory.py +++ b/src/narrativeos/api/app_factory.py @@ -2,19 +2,26 @@ from contextlib import asynccontextmanager import json +import os from pathlib import Path +import threading from time import perf_counter from typing import Any, Dict, List, Optional -from fastapi import FastAPI, HTTPException -from fastapi.responses import FileResponse, RedirectResponse +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from .auth import router as auth_router -from .author import router as author_router -from .ops import router as ops_router +from .billing_provider import router as billing_provider_router +from .quantum_compat import router as quantum_compat_router +from .author import ensure_author_collaboration_access, router as author_router +from .customer import router as customer_router +from .ops import ensure_ops_read_access, ensure_ops_write_access, router as ops_router from .reader import router as reader_router +from .reader_access import ensure_reader_session_access, reader_identity, reader_identity_account_id +from ..core.linter import lint_chapter_draft from ..eval.learned_training_automation import run_learned_training_automation from ..intent import SimpleIntentParser from ..eval.learned_shadow import default_learned_shadow_service @@ -22,9 +29,18 @@ from ..models import EventAtom, NarrativeState, StepRecord, WorldBible, WorldRecord from ..pipeline import plan_next_turn_from_events from ..eval.learned_inference import LearnedInferenceService, default_learned_artifact_dir +from ..eval.service import evaluate_persisted_chapter +from ..long_route_quality import apply_long_route_quality_controls +from ..quality.adapter import enforce_grounding_quality_gate +from ..quality.hard_constraints import enforce_generation_hard_constraints +from ..quality.grounding import build_grounding_check from ..services.analytics import AnalyticsService from ..services.auth import AuthService +from ..services.emailing import EmailService from ..services.author_collaboration import AuthorCollaborationService +from ..services.author_project_graph import AuthorProjectGraphService +from ..services.author_permissions import AuthorPermissionPolicyService +from ..services.author_work import AuthorWorkService from ..services.async_job_adapters import ( build_notification_sink_registry, build_remote_shipping_registry, @@ -32,32 +48,101 @@ from ..services.async_jobs import AsyncJobService from ..services.authoring import AuthoringService from ..services.billing import BillingService +from ..services.commercial_billing import CommercialBillingService +from ..services.commercial_audit import CommercialAuditService +from ..services.commercial_lifecycle_automation import CommercialLifecycleAutomationService +from ..services.commercial_support import CommercialSupportService +from ..services.customer_accounts import CustomerAccountService +from ..services.customer_campaigns import CustomerCampaignService +from ..services.customer_success_reporting import CustomerSuccessReportingService +from ..services.customer_workspace import CustomerWorkspaceService from ..services.data_integrity import DataIntegrityService from ..services.governance import GovernanceService +from ..services.go_live_day_runner import GoLiveDayRunnerService +from ..services.human_signoff_closure import HumanSignoffClosureService from ..services.intent_prefill import IntentPrefillService +from ..services.illustration import ILLUSTRATION_JOB_TYPE, IllustrationService +from ..services.library_stats_cube import LibraryStatsCubeService +from ..services.library_stats_cube_projection import ( + LIBRARY_STATS_INVALIDATION_EVENTS, + LibraryStatsCubeProjectionService, +) +from ..services.library_stats_semantic_layer import LibraryStatsSemanticLayerService +from ..services.launch_week_guard import LaunchWeekGuardService +from ..services.launch_week_monitoring import LaunchWeekMonitoringService from ..services.monetization import MonetizationService from ..services.observability import ObservabilityService from ..services.ops_traceability import OpsTraceabilityService from ..services.ops_alerting import OpsAlertingService +from ..services.ops_commercialization_dashboard import OpsCommercializationDashboardService from ..services.ops_account_workspace import OpsAccountWorkspaceService from ..services.ops_release_workspace import OpsReleaseWorkspaceService from ..services.ops_navigation import OpsNavigationService +from ..services.ops_quality_projection import OpsQualityProjectionService +from ..services.ops_review_hub import OpsReviewHubService +from ..services.ops_permissions import OpsPermissionPolicyService +from ..services.partner_readiness import PartnerReadinessService +from ..services.production_acceptance import ProductionAcceptanceService +from ..services.production_preflight import ProductionPreflightService +from ..services.production_signoff_board import ProductionSignoffBoardService +from ..services.production_handshake_pack import ProductionHandshakePackService +from ..services.production_launch_ledger import ProductionLaunchLedgerService +from ..services.production_launch_week_pack import ProductionLaunchWeekPackService +from ..services.production_signoff import ProductionSignoffService +from ..services.quantum_read_models import QuantumReadModelService +from ..services.launch_command_center import LaunchCommandCenterService +from ..services.wave_activation_controller import WaveActivationControllerService +from ..services.stripe_invoicing import StripeInvoicingService from ..services.provider_routing import ProviderRoutingService from ..services.provider_rollout import ProviderRolloutService from ..services.review import ReviewService from ..services.runtime_ops import RuntimeOpsService -from ..services.sessions import SessionService +from ..services.reader_generation_jobs import READER_GENERATION_JOB_TYPE, ReaderGenerationJobRunner +from ..services.sessions import SessionService, build_reader_continuity_contract from ..services.training_signal import TrainingSignalService from ..rendering import TemplateRenderer from ..repository import SQLAlchemyRepository +from ..sanitizer import sanitize_reader_visible_payload from ..schemas import validate_payload from ..worldpacks.registry import FileSystemWorldRegistry BASE_DIR = Path(__file__).resolve().parents[3] WEB_DIR = Path(__file__).resolve().parents[1] / "web" +DEFAULT_MODERN_FRONTEND_DIST_DIR = BASE_DIR / "Kimi_Agent_设计系统加载" / "app" / "dist" EXAMPLES_DIR = BASE_DIR / "examples" +NO_CACHE_HEADERS = { + "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", + "Pragma": "no-cache", + "Expires": "0", +} + + +class NoCacheStaticFiles(StaticFiles): + def file_response(self, *args, **kwargs): # type: ignore[override] + response = super().file_response(*args, **kwargs) + response.headers.update(NO_CACHE_HEADERS) + return response + + +class FallbackNoCacheStaticFiles(NoCacheStaticFiles): + def __init__(self, *, directory: Path, fallback_directory: Optional[Path] = None): + super().__init__(directory=directory) + self._fallback_static = ( + NoCacheStaticFiles(directory=fallback_directory) + if fallback_directory and fallback_directory != directory and fallback_directory.is_dir() + else None + ) + + async def get_response(self, path: str, scope: Dict[str, Any]): # type: ignore[override] + try: + return await super().get_response(path, scope) + except Exception as exc: + if getattr(exc, "status_code", None) == 404 and self._fallback_static is not None: + return await self._fallback_static.get_response(path, scope) + raise + def load_example_json(name: str) -> Any: return json.loads((EXAMPLES_DIR / name).read_text(encoding="utf-8")) @@ -96,6 +181,51 @@ def build_example_bundle(example_id: str) -> Dict[str, Any]: } +def _env_flag(name: str) -> Optional[bool]: + raw = str(os.getenv(name, "") or "").strip().lower() + if raw in {"1", "true", "yes", "on"}: + return True + if raw in {"0", "false", "no", "off"}: + return False + return None + + +def _modern_frontend_enabled() -> bool: + configured = _env_flag("NARRATIVEOS_SERVE_MODERN_FRONTEND") + if configured is not None: + return configured + return bool(os.getenv("VERCEL")) + + +def _modern_frontend_dist_dir() -> Path: + configured = str(os.getenv("NARRATIVEOS_FRONTEND_DIST_DIR", "") or "").strip() + return Path(configured).expanduser() if configured else DEFAULT_MODERN_FRONTEND_DIST_DIR + + +def _frontend_static_file(frontend_path: str) -> Optional[Path]: + dist_dir = _modern_frontend_dist_dir().resolve() + candidate = (dist_dir / frontend_path).resolve() + if candidate.is_file() and (candidate == dist_dir or dist_dir in candidate.parents): + return candidate + return None + + +def _is_modern_frontend_route(frontend_path: str) -> bool: + first_segment = frontend_path.strip("/").split("/", 1)[0] + return first_segment in { + "", + "author", + "library", + "ops", + "settings", + "showcase", + "soul", + "story", + "studio", + "welcome", + } + + class RoutePreviewRequest(BaseModel): world: Dict[str, Any] state: Dict[str, Any] @@ -115,11 +245,13 @@ class CreateSessionRequest(BaseModel): initial_state: Dict[str, Any] player_profile: Dict[str, Any] = Field(default_factory=dict) metadata: Dict[str, Any] = Field(default_factory=dict) + longform_setup: Dict[str, Any] = Field(default_factory=dict) class StepRequest(BaseModel): player_input: str intent_override: Optional[Dict[str, float]] = None + steering_directive: Optional[Dict[str, Any]] = None candidate_events: Optional[List[Dict[str, Any]]] = None beam_width: int = 3 depth: int = 2 @@ -166,7 +298,17 @@ async def lifespan(app: FastAPI): app.state.repository, monetization_service=app.state.monetization_service, ) - app.state.auth_service = AuthService(app.state.repository) + app.state.customer_account_service = CustomerAccountService( + app.state.repository, + billing_service=app.state.billing_service, + ) + app.state.commercial_audit_service = CommercialAuditService( + app.state.repository, + customer_account_service=app.state.customer_account_service, + ) + app.state.commercial_audit_service.sync_default_retention_policies() + app.state.email_service = EmailService() + app.state.auth_service = AuthService(app.state.repository, email_service=app.state.email_service) app.state.analytics_service = AnalyticsService(app.state.repository) app.state.observability_service = ObservabilityService(app.state.repository) app.state.runtime_ops_service = RuntimeOpsService( @@ -185,10 +327,90 @@ async def lifespan(app: FastAPI): notification_sink_registry=app.state.async_notification_sink_registry, ) app.state.runtime_ops_service.async_job_service = app.state.async_job_service - app.state.review_service = ReviewService(app.state.repository, analytics_service=app.state.analytics_service) + def _schedule_async_job_thread(target, job_id: str) -> None: + thread = threading.Thread(target=target, args=(job_id,), daemon=True) + thread.start() + + reader_job_threads_env = os.getenv("NARRATIVEOS_READER_JOB_BACKGROUND_THREADS") + reader_job_threads_enabled = ( + str(reader_job_threads_env).strip().lower() in {"1", "true", "yes", "on"} + if reader_job_threads_env is not None + else str(os.getenv("VERCEL") or "").strip().lower() not in {"1", "true"} + ) + app.state.reader_generation_job_scheduler = _schedule_async_job_thread if reader_job_threads_enabled else None + app.state.illustration_service = IllustrationService( + app.state.repository, + analytics_service=app.state.analytics_service, + async_job_service=app.state.async_job_service, + job_scheduler=_schedule_async_job_thread, + ) + app.state.ops_quality_projection_service = OpsQualityProjectionService(app.state.repository) + app.state.launch_week_monitoring_service = LaunchWeekMonitoringService( + app.state.repository, + observability_service=app.state.observability_service, + async_job_service=app.state.async_job_service, + quality_projection_service=app.state.ops_quality_projection_service, + base_dir=BASE_DIR, + ) + app.state.commercial_billing_service = CommercialBillingService( + app.state.repository, + billing_service=app.state.billing_service, + customer_account_service=app.state.customer_account_service, + audit_service=app.state.commercial_audit_service, + observability_service=app.state.observability_service, + quality_projection_service=app.state.ops_quality_projection_service, + ) + app.state.commercial_audit_service.commercial_billing = app.state.commercial_billing_service + app.state.stripe_invoicing_service = StripeInvoicingService( + app.state.repository, + monetization_service=app.state.monetization_service, + billing_service=app.state.billing_service, + customer_account_service=app.state.customer_account_service, + audit_service=app.state.commercial_audit_service, + ) + app.state.commercial_support_service = CommercialSupportService( + app.state.repository, + customer_account_service=app.state.customer_account_service, + commercial_billing_service=app.state.commercial_billing_service, + audit_service=app.state.commercial_audit_service, + ) + app.state.customer_campaign_service = CustomerCampaignService( + app.state.repository, + customer_account_service=app.state.customer_account_service, + audit_service=app.state.commercial_audit_service, + ) + app.state.partner_readiness_service = PartnerReadinessService( + app.state.repository, + audit_service=app.state.commercial_audit_service, + ) + app.state.commercial_lifecycle_automation_service = CommercialLifecycleAutomationService( + app.state.repository, + customer_account_service=app.state.customer_account_service, + customer_campaign_service=app.state.customer_campaign_service, + commercial_billing_service=app.state.commercial_billing_service, + commercial_support_service=app.state.commercial_support_service, + audit_service=app.state.commercial_audit_service, + ) + app.state.customer_workspace_service = CustomerWorkspaceService( + customer_account_service=app.state.customer_account_service, + customer_campaign_service=app.state.customer_campaign_service, + partner_readiness_service=app.state.partner_readiness_service, + commercial_billing_service=app.state.commercial_billing_service, + commercial_support_service=app.state.commercial_support_service, + commercial_audit_service=app.state.commercial_audit_service, + commercial_lifecycle_automation_service=app.state.commercial_lifecycle_automation_service, + quality_projection_service=app.state.ops_quality_projection_service, + observability_service=app.state.observability_service, + ) + app.state.review_service = ReviewService( + app.state.repository, + analytics_service=app.state.analytics_service, + quality_projection_service=app.state.ops_quality_projection_service, + ) app.state.governance_service = GovernanceService( app.state.repository, billing_service=app.state.billing_service, + audit_service=app.state.commercial_audit_service, ) app.state.ops_traceability_service = OpsTraceabilityService( app.state.repository, @@ -205,10 +427,12 @@ async def lifespan(app: FastAPI): runtime_ops_service=app.state.runtime_ops_service, async_job_service=app.state.async_job_service, ops_traceability_service=app.state.ops_traceability_service, + audit_service=app.state.commercial_audit_service, ) app.state.ops_account_workspace_service = OpsAccountWorkspaceService( app.state.repository, billing_service=app.state.billing_service, + customer_account_service=app.state.customer_account_service, governance_service=app.state.governance_service, ops_alerting_service=app.state.ops_alerting_service, ops_traceability_service=app.state.ops_traceability_service, @@ -217,7 +441,112 @@ async def lifespan(app: FastAPI): app.state.repository, review_service=app.state.review_service, ops_traceability_service=app.state.ops_traceability_service, + quality_projection_service=app.state.ops_quality_projection_service, + ) + app.state.ops_commercialization_dashboard_service = OpsCommercializationDashboardService( + app.state.repository, + customer_account_service=app.state.customer_account_service, + commercial_support_service=app.state.commercial_support_service, + partner_readiness_service=app.state.partner_readiness_service, + commercial_lifecycle_automation_service=app.state.commercial_lifecycle_automation_service, + launch_week_monitoring_service=app.state.launch_week_monitoring_service, + ) + app.state.production_signoff_service = ProductionSignoffService( + app.state.repository, + audit_service=app.state.commercial_audit_service, + base_dir=BASE_DIR, ) + app.state.ops_commercialization_dashboard_service.production_signoff = app.state.production_signoff_service + app.state.production_acceptance_service = ProductionAcceptanceService( + app.state.repository, + customer_workspace_service=app.state.customer_workspace_service, + production_signoff_service=app.state.production_signoff_service, + audit_service=app.state.commercial_audit_service, + ) + app.state.ops_commercialization_dashboard_service.production_acceptance = app.state.production_acceptance_service + app.state.production_launch_week_pack_service = ProductionLaunchWeekPackService( + production_signoff_service=app.state.production_signoff_service, + production_acceptance_service=app.state.production_acceptance_service, + base_dir=BASE_DIR, + ) + app.state.production_launch_week_pack_service.dashboard_service = app.state.ops_commercialization_dashboard_service + app.state.ops_commercialization_dashboard_service.launch_week_pack = app.state.production_launch_week_pack_service + app.state.production_handshake_pack_service = ProductionHandshakePackService( + production_signoff_service=app.state.production_signoff_service, + production_acceptance_service=app.state.production_acceptance_service, + base_dir=BASE_DIR, + ) + app.state.production_handshake_pack_service.dashboard_service = app.state.ops_commercialization_dashboard_service + app.state.ops_commercialization_dashboard_service.handshake_pack = app.state.production_handshake_pack_service + app.state.production_signoff_board_service = ProductionSignoffBoardService( + production_signoff_service=app.state.production_signoff_service, + ) + app.state.ops_commercialization_dashboard_service.production_signoff_board = app.state.production_signoff_board_service + app.state.production_preflight_service = ProductionPreflightService( + app.state.repository, + production_signoff_service=app.state.production_signoff_service, + base_dir=BASE_DIR, + ) + app.state.ops_commercialization_dashboard_service.production_preflight = app.state.production_preflight_service + app.state.launch_command_center_service = LaunchCommandCenterService( + app.state.repository, + commercialization_dashboard_service=app.state.ops_commercialization_dashboard_service, + production_acceptance_service=app.state.production_acceptance_service, + production_signoff_board_service=app.state.production_signoff_board_service, + production_preflight_service=app.state.production_preflight_service, + ) + app.state.ops_commercialization_dashboard_service.launch_command_center = app.state.launch_command_center_service + app.state.customer_success_reporting_service = CustomerSuccessReportingService( + app.state.repository, + customer_workspace_service=app.state.customer_workspace_service, + customer_account_service=app.state.customer_account_service, + production_acceptance_service=app.state.production_acceptance_service, + production_signoff_service=app.state.production_signoff_service, + commercial_audit_service=app.state.commercial_audit_service, + ) + app.state.ops_commercialization_dashboard_service.customer_success = app.state.customer_success_reporting_service + app.state.production_launch_ledger_service = ProductionLaunchLedgerService( + app.state.repository, + production_signoff_service=app.state.production_signoff_service, + production_acceptance_service=app.state.production_acceptance_service, + production_preflight_service=app.state.production_preflight_service, + launch_command_center_service=app.state.launch_command_center_service, + base_dir=BASE_DIR, + ) + app.state.ops_commercialization_dashboard_service.launch_ledger = app.state.production_launch_ledger_service + app.state.human_signoff_closure_service = HumanSignoffClosureService( + production_signoff_service=app.state.production_signoff_service, + production_signoff_board_service=app.state.production_signoff_board_service, + base_dir=BASE_DIR, + ) + app.state.ops_commercialization_dashboard_service.human_signoff_closure = app.state.human_signoff_closure_service + app.state.wave_activation_controller_service = WaveActivationControllerService( + app.state.repository, + production_signoff_service=app.state.production_signoff_service, + production_acceptance_service=app.state.production_acceptance_service, + production_preflight_service=app.state.production_preflight_service, + launch_command_center_service=app.state.launch_command_center_service, + commercial_audit_service=app.state.commercial_audit_service, + base_dir=BASE_DIR, + ) + app.state.ops_commercialization_dashboard_service.wave_activation = app.state.wave_activation_controller_service + app.state.go_live_day_runner_service = GoLiveDayRunnerService( + app.state.repository, + wave_activation_controller_service=app.state.wave_activation_controller_service, + production_preflight_service=app.state.production_preflight_service, + launch_command_center_service=app.state.launch_command_center_service, + customer_success_reporting_service=app.state.customer_success_reporting_service, + base_dir=BASE_DIR, + ) + app.state.ops_commercialization_dashboard_service.go_live_day_runner = app.state.go_live_day_runner_service + app.state.launch_week_guard_service = LaunchWeekGuardService( + app.state.repository, + customer_success_reporting_service=app.state.customer_success_reporting_service, + launch_command_center_service=app.state.launch_command_center_service, + production_launch_ledger_service=app.state.production_launch_ledger_service, + base_dir=BASE_DIR, + ) + app.state.ops_commercialization_dashboard_service.launch_week_guard = app.state.launch_week_guard_service app.state.ops_navigation_service = OpsNavigationService( app.state.repository, account_workspace_service=app.state.ops_account_workspace_service, @@ -226,6 +555,17 @@ async def lifespan(app: FastAPI): governance_service=app.state.governance_service, ops_traceability_service=app.state.ops_traceability_service, ) + app.state.ops_review_hub_service = OpsReviewHubService( + app.state.repository, + review_service=app.state.review_service, + governance_service=app.state.governance_service, + alerting_service=app.state.ops_alerting_service, + billing_service=app.state.billing_service, + commercial_support_service=app.state.commercial_support_service, + quality_projection_service=app.state.ops_quality_projection_service, + customer_campaign_service=app.state.customer_campaign_service, + ) + app.state.ops_permission_policy = OpsPermissionPolicyService() app.state.training_signal_service = TrainingSignalService(app.state.repository) app.state.learned_inference_service = LearnedInferenceService(default_learned_artifact_dir(BASE_DIR)) app.state.learned_shadow_service = default_learned_shadow_service(BASE_DIR) @@ -241,11 +581,44 @@ async def lifespan(app: FastAPI): provider_routing_service=app.state.provider_routing_service, observability_service=app.state.observability_service, ) + app.state.author_work_service = AuthorWorkService( + app.state.repository, + registry=app.state.world_registry, + provider_routing_service=app.state.provider_routing_service, + analytics_service=app.state.analytics_service, + ) + app.state.library_stats_semantic_layer_service = LibraryStatsSemanticLayerService( + app.state.repository, + ) + app.state.library_stats_cube_service = LibraryStatsCubeService( + app.state.repository, + semantic_layer_service=app.state.library_stats_semantic_layer_service, + ) + app.state.library_stats_cube_projection_service = LibraryStatsCubeProjectionService( + cube_service=app.state.library_stats_cube_service, + ) + app.state.analytics_service.register_listener( + LIBRARY_STATS_INVALIDATION_EVENTS, + app.state.library_stats_cube_projection_service.on_analytics_event, + ) + app.state.quantum_read_model_service = QuantumReadModelService( + app.state.repository, + author_work_service=app.state.author_work_service, + analytics_service=app.state.analytics_service, + billing_service=app.state.billing_service, + library_stats_cube_service=app.state.library_stats_cube_service, + illustration_service=app.state.illustration_service, + ) + app.state.author_project_graph_service = AuthorProjectGraphService( + app.state.repository, + authoring_service=app.state.authoring_service, + ) app.state.author_collaboration_service = AuthorCollaborationService( app.state.repository, analytics_service=app.state.analytics_service, async_job_service=app.state.async_job_service, ) + app.state.author_permission_policy = AuthorPermissionPolicyService() app.state.session_service = SessionService( app.state.repository, intent_parser=app.state.intent_parser, @@ -254,12 +627,36 @@ async def lifespan(app: FastAPI): analytics_service=app.state.analytics_service, observability_service=app.state.observability_service, provider_routing_service=app.state.provider_routing_service, + illustration_service=app.state.illustration_service, ) + @app.middleware("http") + async def enforce_author_collaboration_access_policy(request, call_next): + path = request.url.path.rstrip("/") or request.url.path + if app.state.author_permission_policy.resolve_rule(method=request.method, path=path): + try: + ensure_author_collaboration_access(request) + except HTTPException as exc: + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + return await call_next(request) + + @app.middleware("http") + async def enforce_ops_access(request, call_next): + path = request.url.path.rstrip("/") + if path == "/v1/ops" or path.startswith("/v1/ops/"): + try: + if request.method == "GET": + ensure_ops_read_access(request) + elif request.method in {"POST", "PUT", "PATCH", "DELETE"}: + ensure_ops_write_access(request) + except HTTPException as exc: + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + return await call_next(request) + def _safe_async_job_heartbeat(job_id: str, *, requested_by: str) -> None: try: app.state.async_job_service.heartbeat_job(job_id, requested_by=requested_by) - except KeyError: + except (KeyError, ValueError): return def _run_learned_training_job(job: Dict[str, Any]) -> Dict[str, Any]: @@ -325,29 +722,134 @@ def _run_runtime_restore_job(job: Dict[str, Any]) -> Dict[str, Any]: ) return result + def _run_illustration_generate_job(job: Dict[str, Any]) -> Dict[str, Any]: + _safe_async_job_heartbeat(job["job_id"], requested_by="illustration_generate_runner") + result = app.state.illustration_service.run_generation_job(job) + _safe_async_job_heartbeat(job["job_id"], requested_by="illustration_generate_runner") + return result + + app.state.reader_generation_job_runner = ReaderGenerationJobRunner( + repository=app.state.repository, + session_service=app.state.session_service, + analytics_service=app.state.analytics_service, + ) + + def _run_reader_generation_job(job: Dict[str, Any]) -> Dict[str, Any]: + _safe_async_job_heartbeat(job["job_id"], requested_by="reader_generation_runner") + result = app.state.reader_generation_job_runner.run(job) + _safe_async_job_heartbeat(job["job_id"], requested_by="reader_generation_runner") + return result + app.state.async_job_service.register_runner("learned_training", _run_learned_training_job) app.state.async_job_service.register_runner("runtime_backup", _run_runtime_backup_job) app.state.async_job_service.register_runner("runtime_restore", _run_runtime_restore_job) + app.state.async_job_service.register_runner(ILLUSTRATION_JOB_TYPE, _run_illustration_generate_job) + app.state.async_job_service.register_runner(READER_GENERATION_JOB_TYPE, _run_reader_generation_job) app.state.async_job_boot_reconcile = None - app.mount("/assets", StaticFiles(directory=WEB_DIR), name="assets") + frontend_dist_dir = _modern_frontend_dist_dir() + frontend_static_dir = frontend_dist_dir / "assets" + assets_dir = frontend_static_dir if _modern_frontend_enabled() and frontend_static_dir.is_dir() else WEB_DIR + fallback_assets_dir = WEB_DIR if assets_dir != WEB_DIR else None + app.mount( + "/assets", + FallbackNoCacheStaticFiles(directory=assets_dir, fallback_directory=fallback_assets_dir), + name="assets", + ) + app.include_router(quantum_compat_router) app.include_router(reader_router) + app.include_router(billing_provider_router) app.include_router(auth_router) + app.include_router(customer_router) app.include_router(author_router) app.include_router(ops_router) @app.get("/") - def root() -> RedirectResponse: + def root(): + if _modern_frontend_enabled(): + index_file = _modern_frontend_dist_dir() / "index.html" + if index_file.is_file(): + return FileResponse(index_file, headers=NO_CACHE_HEADERS) return RedirectResponse(url="/app") @app.get("/app") def app_shell() -> FileResponse: - return FileResponse(WEB_DIR / "index.html") + return FileResponse(WEB_DIR / "index.html", headers=NO_CACHE_HEADERS) + + @app.get("/app/user") + def app_user_shell() -> FileResponse: + return FileResponse(WEB_DIR / "index.html", headers=NO_CACHE_HEADERS) + + @app.get("/app/reviewer") + def app_reviewer_shell() -> FileResponse: + return FileResponse(WEB_DIR / "index.html", headers=NO_CACHE_HEADERS) + + @app.get("/app/customer") + def app_customer_shell() -> FileResponse: + return FileResponse(WEB_DIR / "index.html", headers=NO_CACHE_HEADERS) + + @app.get("/app/login") + def app_login_shell() -> FileResponse: + return FileResponse(WEB_DIR / "index.html", headers=NO_CACHE_HEADERS) + + @app.get("/app/signup") + def app_signup_shell() -> FileResponse: + return FileResponse(WEB_DIR / "index.html", headers=NO_CACHE_HEADERS) + + @app.get("/app/verify-email") + def app_verify_email_shell() -> FileResponse: + return FileResponse(WEB_DIR / "index.html", headers=NO_CACHE_HEADERS) + + @app.get("/app/forgot-password") + def app_forgot_password_shell() -> FileResponse: + return FileResponse(WEB_DIR / "index.html", headers=NO_CACHE_HEADERS) + + @app.get("/app/reset-password") + def app_reset_password_shell() -> FileResponse: + return FileResponse(WEB_DIR / "index.html", headers=NO_CACHE_HEADERS) @app.get("/health") def health() -> Dict[str, str]: return {"status": "ok"} + def _world_cover_image(world_version_id: Optional[str]) -> str: + service = getattr(app.state, "illustration_service", None) + if service is None or not world_version_id: + return "" + return service.world_cover_url(world_version_id=str(world_version_id)) + + def _session_cover_image(session_id: str, world_version_id: Optional[str]) -> str: + service = getattr(app.state, "illustration_service", None) + if service is None or not session_id: + return "" + return service.session_cover_url(session_id=session_id) or _world_cover_image(world_version_id) + + def _session_atmosphere_image(session_id: str) -> str: + service = getattr(app.state, "illustration_service", None) + if service is None or not session_id: + return "" + return service.latest_chapter_hero_url(session_id=session_id) + + def _session_media_payload(session_id: str, world_version_id: Optional[str]) -> Dict[str, str]: + return { + "coverImage": _session_cover_image(session_id, world_version_id), + "atmosphereImage": _session_atmosphere_image(session_id), + } + + def _decorate_session_summary(summary: Dict[str, Any]) -> Dict[str, Any]: + world_version_id = str(summary.get("world_version_id") or "") + return { + **summary, + **_session_media_payload(str(summary.get("session_id") or ""), world_version_id), + } + + def _decorate_world_summary(summary: Dict[str, Any]) -> Dict[str, Any]: + world_version_id = str(summary.get("latest_version") or "") + return { + **summary, + "coverImage": _world_cover_image(world_version_id), + } + @app.get("/v1/examples/demo") def demo_bundle() -> Dict[str, Any]: return build_example_bundle("demo") @@ -374,7 +876,7 @@ def get_example(example_id: str) -> Dict[str, Any]: @app.get("/v1/library/worlds") def list_library_worlds() -> Dict[str, Any]: - return {"worlds": app.state.repository.list_worlds()} + return {"worlds": [_decorate_world_summary(item) for item in app.state.repository.list_worlds()]} @app.get("/v1/library/worlds/{world_id}") def get_library_world(world_id: str) -> Dict[str, Any]: @@ -395,7 +897,7 @@ def get_library_world(world_id: str) -> Dict[str, Any]: @app.get("/v1/worlds") def list_worlds() -> Dict[str, Any]: - return {"worlds": app.state.repository.list_worlds()} + return {"worlds": [_decorate_world_summary(item) for item in app.state.repository.list_worlds()]} @app.post("/v1/worlds") def create_world(payload: CreateWorldRequest) -> Dict[str, Any]: @@ -429,12 +931,18 @@ def create_session(payload: CreateSessionRequest) -> Dict[str, Any]: app.state.repository.get_world(payload.world_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) + runtime_bundle = app.state.repository.get_runtime_bundle(next(item["latest_version"] for item in app.state.repository.list_worlds() if item["world_id"] == payload.world_id)) + app.state.session_service._prepare_longform_state( + runtime=runtime_bundle, + state=initial_state, + longform_setup=payload.longform_setup, + ) session_record = app.state.repository.create_session( payload.world_id, initial_state, player_profile=payload.player_profile, - metadata={"reader_id": payload.player_profile.get("reader_id"), **payload.metadata}, + metadata={"reader_id": payload.player_profile.get("reader_id"), "longform_setup_present": bool(payload.longform_setup), **payload.metadata}, ) access = app.state.billing_service.access_check(session_record.session_id, reader_id=payload.player_profile.get("reader_id")) snapshot = { @@ -455,38 +963,77 @@ def create_session(payload: CreateSessionRequest) -> Dict[str, Any]: access_tier=access.get("access_tier"), payload_json=snapshot, ) + app.state.illustration_service.ensure_session_cover( + session_id=session_record.session_id, + reader_id=payload.player_profile.get("reader_id"), + world_version_id=str(session_record.metadata.get("world_version_id") or ""), + ) + app.state.illustration_service.ensure_world_cover( + world_version_id=str(session_record.metadata.get("world_version_id") or ""), + ) + world_version_id = str(session_record.metadata.get("world_version_id") or "") return { "session_id": session_record.session_id, "reader_id": payload.player_profile.get("reader_id"), - "world_version_id": session_record.metadata.get("world_version_id"), + "world_version_id": world_version_id, "current_state": session_record.current_state.to_dict(), "paywall": access, + "steering_checkpoint": dict(initial_state.storyline_checkpoint or {}), + **_session_media_payload(session_record.session_id, world_version_id), } @app.get("/v1/sessions") - def list_sessions(world_id: Optional[str] = None) -> Dict[str, Any]: - return {"sessions": app.state.repository.list_sessions(world_id=world_id)} + def list_sessions(request: Request, world_id: Optional[str] = None) -> Dict[str, Any]: + account_id = reader_identity_account_id(reader_identity(request)) + if not account_id: + return {"sessions": []} + return { + "sessions": [ + _decorate_session_summary(item) + for item in app.state.repository.list_sessions(world_id=world_id, reader_id=account_id) + ] + } @app.get("/v1/sessions/{session_id}") - def get_session(session_id: str) -> Dict[str, Any]: + def get_session(session_id: str, request: Request) -> Dict[str, Any]: try: - session_record = app.state.repository.get_session(session_id) + session_record = ensure_reader_session_access(request, session_id=session_id) latest_step = app.state.repository.get_latest_step(session_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) + session_payload = session_record.to_dict() + world_version_id = str(session_record.metadata.get("world_version_id") or "") + session_payload.update(_session_media_payload(session_id, world_version_id)) return { - "session": session_record.to_dict(), + "session": session_payload, "latest_step": latest_step.to_dict() if latest_step else None, - "world_version_id": session_record.metadata.get("world_version_id"), + "world_version_id": world_version_id, "paywall": app.state.billing_service.access_check(session_id, reader_id=session_record.metadata.get("reader_id")), "entitlements_snapshot": session_record.metadata.get("entitlements_snapshot", {}), "intent_prefill": app.state.intent_prefill_service.build(session_record, latest_step).to_dict(), + **_session_media_payload(session_id, world_version_id), } @app.delete("/v1/sessions/{session_id}") - def delete_session(session_id: str) -> Dict[str, Any]: + def delete_session(session_id: str, request: Request) -> Dict[str, Any]: try: - return app.state.repository.delete_session(session_id) + session_record = ensure_reader_session_access(request, session_id=session_id) + deleted = app.state.repository.delete_session(session_id) + account_id = ( + str(session_record.metadata.get("account_id") or "").strip() + or str(session_record.metadata.get("reader_id") or session_record.player_profile.get("reader_id") or "").strip() + or None + ) + if account_id: + app.state.analytics_service.track( + "session_deleted", + reader_id=account_id, + session_id=session_id, + world_id=session_record.world_id, + world_version_id=session_record.metadata.get("world_version_id"), + payload_json={"account_id": account_id}, + ) + return deleted except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) @@ -516,11 +1063,12 @@ def route_preview(payload: RoutePreviewRequest) -> Dict[str, Any]: def step_session( session_id: str, payload: StepRequest, + request: Request, debug: bool = False, mode: Optional[str] = None, ) -> Dict[str, Any]: try: - session_record = app.state.repository.get_session(session_id) + session_record = ensure_reader_session_access(request, session_id=session_id) world_record = app.state.repository.get_world(session_record.world_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) @@ -570,13 +1118,32 @@ def step_session( "updated_state_summary": None, "replay_preview": None, "paywall": access, + "continuity_contract": build_reader_continuity_contract( + status=blocked_status, + session_id=session_id, + paywall=access, + ), } state_before = NarrativeState.from_dict(session_record.current_state.to_dict()) + runtime_bundle = app.state.repository.get_runtime_bundle(session_record.metadata.get("world_version_id")) + app.state.session_service._prepare_longform_state(runtime=runtime_bundle, state=state_before) state_before.player_intent = app.state.intent_parser.parse( payload.player_input, overrides=payload.intent_override, ) + steering_checkpoint = {} + if payload.steering_directive: + from ..longform import apply_steering_directive + + steering_directive = dict(payload.steering_directive or {}) + steering_directive.setdefault("current_user_intent", payload.player_input) + steering_result = apply_steering_directive( + state_before, + steering_directive, + world=world_record.world, + ) + steering_checkpoint = dict(steering_result.get("replan_checkpoint") or {}) if payload.candidate_events: candidate_events = [EventAtom.from_dict(item) for item in payload.candidate_events] @@ -624,6 +1191,16 @@ def step_session( debug=True, ) runtime_latency_ms = round((perf_counter() - started) * 1000.0, 3) + if result.get("status") == "ok" and isinstance(result.get("reader_view"), dict): + sanitized_reader_view, reader_visible_language_debug = sanitize_reader_visible_payload(dict(result["reader_view"] or {})) + result["reader_view"] = sanitized_reader_view + if isinstance(result.get("rendered_scene"), dict): + rendered_debug = dict((result["rendered_scene"].get("debug") or {})) + rendered_debug["reader_visible_language_debug"] = reader_visible_language_debug + result["rendered_scene"] = { + **dict(result["rendered_scene"] or {}), + "debug": rendered_debug, + } chosen_event = EventAtom.from_dict(result["chosen_event"]) if result.get("chosen_event") else None state_after = ( @@ -631,6 +1208,169 @@ def step_session( if result.get("updated_state") else session_record.current_state ) + chapter_task = dict((result.get("chapter_plan") or {}).get("chapter_task") or {}) + 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": chapter_task, + } + if result.get("status") == "ok" and isinstance(result.get("reader_view"), dict): + repaired_reader_view, state_after, long_route_quality_debug = apply_long_route_quality_controls( + dict(result.get("reader_view") or {}), + state_before=state_before, + state_after=state_after, + coverage_context=coverage_context, + ) + result["reader_view"] = repaired_reader_view + result["updated_state"] = state_after.to_dict() + if isinstance(result.get("rendered_scene"), dict): + rendered_debug = dict((result["rendered_scene"].get("debug") or {})) + rendered_debug["long_route_quality_controls"] = long_route_quality_debug + result["rendered_scene"] = { + **dict(result["rendered_scene"] or {}), + "debug": rendered_debug, + } + body = str((result.get("reader_view") or {}).get("body") or "") + lint_report = lint_chapter_draft(body) if body else {} + quality_bundle = None + target_chapters = int(getattr(getattr(runtime_bundle.worldpack, "series_plan", None), "total_chapter_target", 0) or 100) + if result["status"] == "ok": + quality_bundle = evaluate_persisted_chapter( + chapter_id="chapter_%s_%s" % (session_id, state_after.chapter_index), + world_version_id=session_record.metadata.get("world_version_id"), + session_id=session_id, + body=body, + paragraphs=body.split("\n\n"), + dialogue_count=int(lint_report.get("dialogue_count", 0)), + action_count=int(lint_report.get("action_count", 0)), + detail_count=int(lint_report.get("detail_count", 0)), + character_fidelity_score=max( + [item["components"].get("character_fidelity", 0.0) for item in result.get("scored_candidates", [])], + default=0.0, + ), + state_after=state_after, + ending_ready=bool((result.get("chapter_plan") or {}).get("ending_ready")), + chapter_title=(result.get("reader_view") or {}).get("chapter_title"), + recap=(result.get("reader_view") or {}).get("recap"), + relationship_hints=list((result.get("reader_view") or {}).get("relationship_hints") or []), + choices=list((result.get("reader_view") or {}).get("choices") or []), + paywall_required=bool(access["required"]), + coverage_context=coverage_context, + target_words=app.state.session_service._effective_target_words( + chapter_task.get("target_words"), + chapter_index=int(state_after.chapter_index or 0), + story_phase=str(state_after.story_phase or ""), + ), + min_target_words=app.state.session_service._effective_min_target_words( + runtime_bundle, + chapter_index=int(state_after.chapter_index or 0), + story_phase=str(state_after.story_phase or ""), + ), + chapter_index=int(state_after.chapter_index or 0), + target_chapters=target_chapters, + story_phase=str(state_after.story_phase or ""), + rolling_quality_window=list((state_before.metadata or {}).get("quality_contract_window", [])), + enforcement_scope="session_api_generation", + ) + grounding_check = build_grounding_check( + scenario_id="reader_continue", + text=body, + source_surface="reader", + world_version_id=session_record.metadata.get("world_version_id"), + session_id=session_id, + chapter_id="chapter_%s_%s" % (session_id, state_after.chapter_index), + coverage_context=coverage_context, + state_after=state_after, + worldpack_payload=runtime_bundle.worldpack.to_dict() if hasattr(runtime_bundle.worldpack, "to_dict") else None, + ) + quality_bundle = enforce_grounding_quality_gate( + quality_bundle, + grounding_check=grounding_check, + source_surface="reader", + ) + worldpack_payload = runtime_bundle.worldpack.to_dict() if hasattr(runtime_bundle.worldpack, "to_dict") else None + quality_bundle = enforce_generation_hard_constraints( + quality_bundle, + reader_view=dict(result.get("reader_view") or {}), + grounding_check=grounding_check, + source_surface="reader", + target_chapters=target_chapters, + worldpack_payload=worldpack_payload, + repair_report=long_route_quality_debug, + ) + from ..longform import record_replan_debt + + record_replan_debt( + state_after, + chapter_index=int(state_after.chapter_index or 0), + issue_codes=[issue.issue_code for issue in quality_bundle["report"].issues], + ) + if not quality_bundle["quality_gate"]["ok"]: + app.state.analytics_service.track( + "chapter_quality_guard_failed", + reader_id=reader_id, + account_id=app.state.billing_service.resolve_account_id(reader_id=reader_id), + session_id=session_id, + world_id=world_record.world.world_id, + world_version_id=session_record.metadata.get("world_version_id"), + chapter_index=state_after.chapter_index, + access_tier=access.get("access_tier"), + payload_json={ + "surface": "session_api_generation", + "quality_gate": quality_bundle["quality_gate"], + }, + ) + app.state.observability_service.record_runtime_receipt( + surface="session_api", + action="step_session", + response_status="quality_guard_failed", + world_id=world_record.world.world_id, + world_version_id=session_record.metadata.get("world_version_id"), + session_id=session_id, + account_id=app.state.billing_service.resolve_account_id(reader_id=reader_id), + reader_id=reader_id, + candidate_batch=result.get("candidate_batch"), + rendered_scene=result.get("rendered_scene"), + reader_view=None, + estimated_cost=0.0, + runtime_latency_ms=runtime_latency_ms, + ) + base_response = { + "status": "quality_guard_failed", + "code": quality_bundle["quality_gate"]["code"], + "quality_gate": quality_bundle["quality_gate"], + "world_version_id": session_record.metadata.get("world_version_id"), + "reader_view": None, + "updated_state_summary": None, + "replay_preview": None, + "paywall": access, + "steering_checkpoint": steering_checkpoint or dict(state_after.storyline_checkpoint or {}), + "replan_checkpoint": dict(state_after.replan_checkpoint or {}), + "continuity_contract": build_reader_continuity_contract( + status="quality_guard_failed", + session_id=session_id, + paywall=access, + quality_gate=quality_bundle["quality_gate"], + ), + } + if debug or mode == "debug": + base_response.update( + { + "chosen_event": result.get("chosen_event"), + "updated_state": result.get("updated_state"), + "scored_candidates": result.get("scored_candidates"), + "critic_trace": result.get("critic_trace"), + "rendered_scene": result.get("rendered_scene"), + "candidate_batch": result.get("candidate_batch"), + "routes": result.get("routes"), + "chapter_plan": result.get("chapter_plan"), + } + ) + return base_response + state_after.metadata = { + **dict(state_after.metadata or {}), + "quality_contract_window": list(quality_bundle["quality_gate"].get("quality_contract_window") or []), + } step_record = StepRecord.from_dict( { "session_id": session_id, @@ -653,6 +1393,8 @@ def step_session( "metadata": payload.metadata, } ) + step_record.metadata["steering_directive"] = dict(payload.steering_directive or {}) + step_record.metadata["steering_checkpoint"] = steering_checkpoint consumed_access = app.state.billing_service.consume_entitlement(session_id, reader_id=reader_id, access=access) if result["status"] == "ok": snapshot = { @@ -669,6 +1411,13 @@ def step_session( cost_estimate=round(max(1, len(result["reader_view"]["body"])) / 1200.0, 3), ) app.state.repository.update_session_entitlements_snapshot(session_id, snapshot) + app.state.illustration_service.ensure_chapter_hero( + session_id=session_id, + reader_id=reader_id, + world_version_id=str(session_record.metadata.get("world_version_id") or ""), + chapter_index=int(step_record.step_index or 0), + rendered_scene=dict(result.get("rendered_scene") or {}), + ) app.state.billing_service.meter_action( surface="reader", action_name="continue_story", @@ -742,6 +1491,14 @@ def step_session( "updated_state_summary": result.get("updated_state_summary"), "replay_preview": result.get("replay_preview"), "paywall": paywall, + "steering_checkpoint": steering_checkpoint or dict(state_after.storyline_checkpoint or {}), + "replan_checkpoint": dict(state_after.replan_checkpoint or {}), + "continuity_contract": build_reader_continuity_contract( + status=str(result["status"]), + session_id=session_id, + paywall=paywall, + ), + **_session_media_payload(session_id, str(session_record.metadata.get("world_version_id") or "")), } if debug or mode == "debug": base_response.update( @@ -761,22 +1518,46 @@ def step_session( return base_response @app.get("/v1/sessions/{session_id}/replay") - def replay_session(session_id: str) -> Dict[str, Any]: + def replay_session( + session_id: str, + request: Request, + start_chapter: Optional[int] = None, + end_chapter: Optional[int] = None, + limit: Optional[int] = None, + latest: bool = False, + ) -> Dict[str, Any]: try: - return app.state.repository.get_replay(session_id) + ensure_reader_session_access(request, session_id=session_id) + return app.state.repository.get_replay( + session_id, + start_chapter=start_chapter, + end_chapter=end_chapter, + limit=limit, + latest=latest, + ) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) @app.get("/v1/sessions/{session_id}/prefill") - def session_prefill(session_id: str) -> Dict[str, Any]: + def session_prefill(session_id: str, request: Request) -> Dict[str, Any]: try: - session_record = app.state.repository.get_session(session_id) + session_record = ensure_reader_session_access(request, session_id=session_id) latest_step = app.state.repository.get_latest_step(session_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) return app.state.intent_prefill_service.build(session_record, latest_step).to_dict() - return app - + @app.get("/{frontend_path:path}", include_in_schema=False) + def modern_frontend_shell(frontend_path: str) -> FileResponse: + if not _modern_frontend_enabled(): + raise HTTPException(status_code=404, detail="Not Found") + static_file = _frontend_static_file(frontend_path) + if static_file is not None: + return FileResponse(static_file, headers=NO_CACHE_HEADERS) + if _is_modern_frontend_route(frontend_path): + index_file = _modern_frontend_dist_dir() / "index.html" + if index_file.is_file(): + return FileResponse(index_file, headers=NO_CACHE_HEADERS) + raise HTTPException(status_code=404, detail="Not Found") -app = create_app() + return app diff --git a/src/narrativeos/api/auth.py b/src/narrativeos/api/auth.py index 47c1951..e17f6b6 100644 --- a/src/narrativeos/api/auth.py +++ b/src/narrativeos/api/auth.py @@ -1,10 +1,13 @@ from __future__ import annotations +import json from typing import Any, Dict, Optional -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, HTTPException, Request, Response from pydantic import BaseModel +from ..services.auth import AuthServiceError + class AuthRegisterRequest(BaseModel): actor_id: str @@ -19,56 +22,478 @@ class AuthLoginRequest(BaseModel): password: str +class AuthRefreshRequest(BaseModel): + refresh_token: str + + +class AuthProfileUpdateRequest(BaseModel): + display_name: Optional[str] = None + avatar_url: Optional[str] = None + email_address: Optional[str] = None + + +class AuthPasswordChangeRequest(BaseModel): + current_password: str + new_password: str + + +class AuthEmailChangeRequest(BaseModel): + new_email: str + current_password: str + + +class AuthVerificationRequest(BaseModel): + actor_id: Optional[str] = None + + +class AuthVerificationConfirmRequest(BaseModel): + token: str + + +class AuthPasswordResetRequest(BaseModel): + actor_id: str + + +class AuthPasswordResetConfirmRequest(BaseModel): + token: str + new_password: str + + +class AdminViewBridgeRequest(BaseModel): + workspace: str = "review" + account_id: Optional[str] = None + world_id: Optional[str] = None + world_version_id: Optional[str] = None + case_id: Optional[str] = None + alert_id: Optional[str] = None + + +class AdminViewSessionBridgeRequest(AdminViewBridgeRequest): + actor_id: str + password: str + + +class AdminViewBridgeResolveRequest(BaseModel): + token: str + + router = APIRouter(prefix="/v1/auth", tags=["auth"]) -def _bearer_token(request: Request) -> str: - authorization = request.headers.get("Authorization") or "" - if not authorization.lower().startswith("bearer "): +def _request_token(request: Request) -> str: + token = request.app.state.auth_service.extract_request_token( + authorization=request.headers.get("Authorization"), + cookies=request.cookies, + ) + if not token: raise HTTPException(status_code=401, detail={"code": "missing_bearer_token"}) - return authorization.split(" ", 1)[1].strip() + return token + + +def _raise_auth_service_error(exc: AuthServiceError) -> None: + raise HTTPException(status_code=exc.http_status, detail=exc.detail()) from exc + + +def _auth_identifier_from_request(request: Request, *, raw_identifier: str) -> str: + try: + return request.app.state.auth_service.resolve_actor_id_from_identifier(raw_identifier) + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "auth_identifier_invalid", "reason": str(exc)}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "auth_identity_missing", "reason": str(exc)}) from exc + + +def _auth_export_payload(request: Request, *, identity: Dict[str, Any]) -> Dict[str, Any]: + account_id = str(identity.get("account_id") or identity.get("actor_id") or "") + security = { + "email_address": identity.get("email_address"), + "pending_email_address": identity.get("pending_email_address"), + "avatar_url": identity.get("avatar_url"), + "email_verified": bool(identity.get("email_verified")), + "verification_required": bool(identity.get("verification_required")), + "verification_sent_at": identity.get("verification_sent_at"), + "verified_at": identity.get("verified_at"), + "password_reset_sent_at": identity.get("password_reset_sent_at"), + "pending_email_change_requested_at": identity.get("pending_email_change_requested_at"), + "email_change_last_sent_at": identity.get("email_change_last_sent_at"), + "ui_preferences": dict(identity.get("ui_preferences") or {}), + "deactivated_at": identity.get("deactivated_at"), + "deactivated_by": identity.get("deactivated_by"), + "deactivation_reason": identity.get("deactivation_reason"), + } + billing = request.app.state.billing_service.subscription_status(account_id=account_id) + sessions = [] + for item in request.app.state.repository.list_sessions(): + try: + detail = request.app.state.repository.get_session(str(item.get("session_id") or "")) + except KeyError: + continue + owner_account_id = str(detail.metadata.get("reader_id") or detail.player_profile.get("reader_id") or "").strip() + if owner_account_id != account_id: + continue + sessions.append( + { + "session_id": item.get("session_id"), + "world_id": item.get("world_id"), + "world_version_id": item.get("world_version_id"), + "current_turn_index": item.get("current_turn_index"), + "last_event_title": item.get("last_event_title"), + "last_chapter_title": item.get("last_chapter_title"), + "created_at": item.get("created_at"), + } + ) + if len(sessions) >= 10: + break + author_works = [ + { + "work_id": item.get("work_id"), + "world_version_id": item.get("world_version_id"), + "title": item.get("title"), + "status": item.get("status"), + "chapter_count": item.get("chapter_count"), + "target_chapter_count": item.get("target_chapter_count"), + "updated_at": item.get("updated_at"), + } + for item in request.app.state.repository.list_author_works(account_id=account_id, limit=10) + ] + deletion_requests = [ + { + "deletion_request_id": item.get("deletion_request_id"), + "scope": item.get("scope"), + "status": item.get("status"), + "requested_by": item.get("requested_by"), + "updated_at": item.get("updated_at"), + } + for item in request.app.state.repository.list_data_deletion_requests(account_id=account_id, limit=10) + ] + export_payload = { + "generated_at": request.app.state.auth_service._utcnow(), + "identity": { + "actor_id": identity.get("actor_id"), + "account_id": identity.get("account_id"), + "actor_role": identity.get("actor_role"), + "display_name": identity.get("display_name"), + "created_at": identity.get("created_at"), + }, + "profile": security, + "billing": { + "effective_tier": billing.get("effective_tier"), + "subscription": billing.get("subscription"), + "wallets": billing.get("wallets"), + "customer_id": billing.get("customer_id"), + "customer_portal_available": billing.get("customer_portal_available"), + }, + "library_summary": { + "recent_reader_sessions": sessions, + "author_works": author_works, + }, + "audit_summary": { + "data_deletion_requests": deletion_requests, + "latest_checkout_session": billing.get("latest_checkout_session") or billing.get("checkout_session"), + "recent_checkout_sessions": billing.get("recent_checkout_sessions"), + }, + } + content = json.dumps(export_payload, ensure_ascii=False, indent=2) + filename_account_id = account_id.replace("@", "_at_").replace("/", "_") + return { + "filename": f"account_export_{filename_account_id}.json", + "content_type": "application/json", + "content": content, + } @router.post("/register") def register_auth_identity(payload: AuthRegisterRequest, request: Request) -> Dict[str, Any]: try: - return request.app.state.auth_service.register_identity( + result = request.app.state.auth_service.register_identity( actor_id=payload.actor_id, actor_role=payload.actor_role, password=payload.password, account_id=payload.account_id, display_name=payload.display_name, ) + if payload.actor_role == "customer" and hasattr(request.app.state, "customer_account_service"): + request.app.state.customer_account_service.ensure_customer_account( + account_id=str(result["identity"].get("account_id") or result["identity"].get("actor_id") or ""), + display_name=result["identity"].get("display_name") or result["identity"].get("actor_id"), + ) + return result + except AuthServiceError as exc: + _raise_auth_service_error(exc) except ValueError as exc: raise HTTPException(status_code=400, detail={"code": "auth_register_invalid", "reason": str(exc)}) from exc @router.post("/login") -def login_auth_identity(payload: AuthLoginRequest, request: Request) -> Dict[str, Any]: +def login_auth_identity(payload: AuthLoginRequest, request: Request, response: Response) -> Dict[str, Any]: try: - return request.app.state.auth_service.issue_token(actor_id=payload.actor_id, password=payload.password) + result = request.app.state.auth_service.issue_token(actor_id=_auth_identifier_from_request(request, raw_identifier=payload.actor_id), password=payload.password) + response.set_cookie( + value=result["token"]["access_token"], + **request.app.state.auth_service.auth_cookie_settings(), + ) + return result + except AuthServiceError as exc: + _raise_auth_service_error(exc) except PermissionError as exc: raise HTTPException(status_code=401, detail={"code": "auth_login_failed", "reason": str(exc)}) from exc + + +@router.post("/refresh") +def refresh_auth_identity(payload: AuthRefreshRequest, request: Request, response: Response) -> Dict[str, Any]: + try: + result = request.app.state.auth_service.refresh_access_token(raw_refresh_token=payload.refresh_token) + response.set_cookie( + value=result["token"]["access_token"], + **request.app.state.auth_service.auth_cookie_settings(), + ) + return result + except AuthServiceError as exc: + _raise_auth_service_error(exc) + except PermissionError as exc: + raise HTTPException(status_code=401, detail={"code": "auth_refresh_failed", "reason": str(exc)}) from exc except KeyError as exc: - raise HTTPException(status_code=404, detail={"code": "auth_identity_missing", "reason": str(exc)}) from exc + raise HTTPException(status_code=404, detail={"code": "auth_refresh_missing", "reason": str(exc)}) from exc @router.get("/me") def auth_me(request: Request) -> Dict[str, Any]: try: - return {"identity": request.app.state.auth_service.resolve_bearer_token(_bearer_token(request))} + return {"identity": request.app.state.auth_service.resolve_bearer_token(_request_token(request))} except PermissionError as exc: raise HTTPException(status_code=401, detail={"code": "auth_token_invalid", "reason": str(exc)}) from exc except KeyError as exc: raise HTTPException(status_code=404, detail={"code": "auth_token_missing", "reason": str(exc)}) from exc +@router.put("/profile") +def update_auth_profile(payload: AuthProfileUpdateRequest, request: Request) -> Dict[str, Any]: + try: + identity = request.app.state.auth_service.resolve_bearer_token(_request_token(request)) + updated = request.app.state.auth_service.update_profile( + actor_id=str(identity.get("actor_id") or ""), + display_name=payload.display_name, + avatar_url=payload.avatar_url, + email_address=payload.email_address, + ) + return {"identity": updated["identity"]} + except AuthServiceError as exc: + _raise_auth_service_error(exc) + except PermissionError as exc: + raise HTTPException(status_code=401, detail={"code": "auth_token_invalid", "reason": str(exc)}) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "auth_profile_invalid", "reason": str(exc)}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "auth_identity_missing", "reason": str(exc)}) from exc + + +@router.post("/password-change") +def change_auth_password(payload: AuthPasswordChangeRequest, request: Request, response: Response) -> Dict[str, Any]: + try: + identity = request.app.state.auth_service.resolve_bearer_token(_request_token(request)) + result = request.app.state.auth_service.change_password( + actor_id=str(identity.get("actor_id") or ""), + current_password=payload.current_password, + new_password=payload.new_password, + ) + response.set_cookie( + value=result["token"]["access_token"], + **request.app.state.auth_service.auth_cookie_settings(), + ) + return result + except AuthServiceError as exc: + _raise_auth_service_error(exc) + except PermissionError as exc: + raise HTTPException(status_code=401, detail={"code": "auth_password_change_failed", "reason": str(exc)}) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "auth_password_change_invalid", "reason": str(exc)}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "auth_identity_missing", "reason": str(exc)}) from exc + + +@router.get("/export") +def export_auth_account(request: Request, format: str = "json") -> Dict[str, Any]: + if str(format or "").strip().lower() != "json": + raise HTTPException(status_code=400, detail={"code": "auth_export_invalid", "reason": "unsupported_export_format"}) + try: + identity = request.app.state.auth_service.resolve_bearer_token(_request_token(request)) + return _auth_export_payload(request, identity=identity) + except PermissionError as exc: + raise HTTPException(status_code=401, detail={"code": "auth_token_invalid", "reason": str(exc)}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "auth_identity_missing", "reason": str(exc)}) from exc + + +@router.post("/email-change/request") +def request_auth_email_change(payload: AuthEmailChangeRequest, request: Request) -> Dict[str, Any]: + try: + identity = request.app.state.auth_service.resolve_bearer_token(_request_token(request)) + return request.app.state.auth_service.request_email_change( + actor_id=str(identity.get("actor_id") or ""), + current_password=payload.current_password, + new_email=payload.new_email, + ) + except AuthServiceError as exc: + _raise_auth_service_error(exc) + except PermissionError as exc: + raise HTTPException(status_code=401, detail={"code": "auth_email_change_request_failed", "reason": str(exc)}) from exc + except ValueError as exc: + reason = str(exc) + status_code = 409 if reason in {"email_change_email_already_in_use", "email_change_email_pending_elsewhere"} else 400 + raise HTTPException(status_code=status_code, detail={"code": "auth_email_change_request_invalid", "reason": reason}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "auth_identity_missing", "reason": str(exc)}) from exc + + +@router.post("/email-change/confirm") +def confirm_auth_email_change(payload: AuthVerificationConfirmRequest, request: Request) -> Dict[str, Any]: + try: + return request.app.state.auth_service.confirm_email_change(token=payload.token) + except AuthServiceError as exc: + _raise_auth_service_error(exc) + except PermissionError as exc: + raise HTTPException(status_code=401, detail={"code": "auth_email_change_token_invalid", "reason": str(exc)}) from exc + except ValueError as exc: + reason = str(exc) + status_code = 409 if reason == "email_change_email_already_in_use" else 400 + raise HTTPException(status_code=status_code, detail={"code": "auth_email_change_invalid", "reason": reason}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "auth_identity_missing", "reason": str(exc)}) from exc + + @router.post("/logout") -def auth_logout(request: Request) -> Dict[str, Any]: +def auth_logout(request: Request, response: Response) -> Dict[str, Any]: try: - revoked = request.app.state.auth_service.revoke_bearer_token(_bearer_token(request)) + revoked = request.app.state.auth_service.revoke_bearer_token(_request_token(request)) + response.delete_cookie( + key=request.app.state.auth_service.auth_cookie_settings()["key"], + path="/", + domain=request.app.state.auth_service.auth_cookie_settings().get("domain"), + ) return {"session": revoked} except PermissionError as exc: raise HTTPException(status_code=401, detail={"code": "auth_token_invalid", "reason": str(exc)}) from exc except KeyError as exc: raise HTTPException(status_code=404, detail={"code": "auth_token_missing", "reason": str(exc)}) from exc + + +@router.post("/admin-view-bridge") +def create_admin_view_bridge(payload: AdminViewBridgeRequest, request: Request) -> Dict[str, Any]: + try: + identity = request.app.state.auth_service.resolve_bearer_token(_request_token(request)) + return request.app.state.auth_service.issue_admin_view_bridge( + actor_id=str(identity.get("actor_id") or ""), + actor_role=str(identity.get("actor_role") or ""), + account_id=payload.account_id or identity.get("account_id"), + workspace=payload.workspace, + world_id=payload.world_id, + world_version_id=payload.world_version_id, + case_id=payload.case_id, + alert_id=payload.alert_id, + ) + except PermissionError as exc: + raise HTTPException(status_code=403, detail={"code": "admin_view_bridge_forbidden", "reason": str(exc)}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "auth_identity_missing", "reason": str(exc)}) from exc + + +@router.post("/admin-view-session-bridge") +def create_admin_view_session_bridge(payload: AdminViewSessionBridgeRequest, request: Request) -> Dict[str, Any]: + try: + return request.app.state.auth_service.issue_admin_view_session_bridge( + actor_id=payload.actor_id, + password=payload.password, + account_id=payload.account_id, + workspace=payload.workspace, + world_id=payload.world_id, + world_version_id=payload.world_version_id, + case_id=payload.case_id, + alert_id=payload.alert_id, + ) + except PermissionError as exc: + raise HTTPException(status_code=403, detail={"code": "admin_view_bridge_forbidden", "reason": str(exc)}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "auth_identity_missing", "reason": str(exc)}) from exc + + +@router.post("/admin-view-bridge/resolve") +def resolve_admin_view_bridge(payload: AdminViewBridgeResolveRequest, request: Request) -> Dict[str, Any]: + try: + authorization = request.headers.get("Authorization") or "" + if authorization.lower().startswith("bearer "): + token = request.app.state.auth_service.extract_request_token( + authorization=authorization, + cookies=None, + ) + identity = request.app.state.auth_service.resolve_bearer_token(token or "") + return request.app.state.auth_service.resolve_admin_view_bridge( + raw_token=payload.token, + actor_id=str(identity.get("actor_id") or ""), + actor_role=str(identity.get("actor_role") or ""), + ) + return request.app.state.auth_service.resolve_admin_view_bridge_token(raw_token=payload.token) + except PermissionError as exc: + raise HTTPException(status_code=403, detail={"code": "admin_view_bridge_forbidden", "reason": str(exc)}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "admin_view_bridge_missing", "reason": str(exc)}) from exc + + +@router.post("/verification/request") +def request_auth_verification(payload: AuthVerificationRequest, request: Request) -> Dict[str, Any]: + actor_id = payload.actor_id + actor_id_from_token = False + if not actor_id: + try: + actor_id = request.app.state.auth_service.resolve_bearer_token(_request_token(request)).get("actor_id") + actor_id_from_token = True + except HTTPException: + actor_id = None + except (PermissionError, KeyError): + actor_id = None + if not actor_id: + raise HTTPException(status_code=400, detail={"code": "auth_verification_invalid", "reason": "actor_id_required"}) + try: + resolved_actor_id = str(actor_id) if actor_id_from_token else _auth_identifier_from_request(request, raw_identifier=actor_id) + return request.app.state.auth_service.request_email_verification(actor_id=resolved_actor_id) + except AuthServiceError as exc: + _raise_auth_service_error(exc) + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "auth_verification_invalid", "reason": str(exc)}) from exc + + +@router.post("/verification/confirm") +def confirm_auth_verification(payload: AuthVerificationConfirmRequest, request: Request) -> Dict[str, Any]: + try: + return request.app.state.auth_service.confirm_email_verification(token=payload.token) + except AuthServiceError as exc: + _raise_auth_service_error(exc) + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "auth_verification_invalid", "reason": str(exc)}) from exc + except PermissionError as exc: + raise HTTPException(status_code=401, detail={"code": "auth_verification_token_invalid", "reason": str(exc)}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "auth_identity_missing", "reason": str(exc)}) from exc + + +@router.post("/password-reset/request") +def request_password_reset(payload: AuthPasswordResetRequest, request: Request) -> Dict[str, Any]: + try: + return request.app.state.auth_service.request_password_reset(actor_id=_auth_identifier_from_request(request, raw_identifier=payload.actor_id)) + except AuthServiceError as exc: + _raise_auth_service_error(exc) + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "auth_password_reset_invalid", "reason": str(exc)}) from exc + + +@router.post("/password-reset/confirm") +def confirm_password_reset(payload: AuthPasswordResetConfirmRequest, request: Request) -> Dict[str, Any]: + try: + return request.app.state.auth_service.confirm_password_reset(token=payload.token, new_password=payload.new_password) + except AuthServiceError as exc: + _raise_auth_service_error(exc) + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "auth_password_reset_invalid", "reason": str(exc)}) from exc + except PermissionError as exc: + raise HTTPException(status_code=401, detail={"code": "auth_password_reset_token_invalid", "reason": str(exc)}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "auth_identity_missing", "reason": str(exc)}) from exc diff --git a/src/narrativeos/api/author.py b/src/narrativeos/api/author.py index b45a6a5..d174fe2 100644 --- a/src/narrativeos/api/author.py +++ b/src/narrativeos/api/author.py @@ -3,8 +3,11 @@ from typing import Any, Dict, Optional from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse from pydantic import BaseModel, Field +from ..eval.service import ChapterQualityGuardError + class SaveDraftRequest(BaseModel): worldpack: Dict[str, Any] @@ -21,6 +24,111 @@ class AuthorAccountRequest(BaseModel): account_id: Optional[str] = None +class AuthorLongformBootstrapRequest(BaseModel): + account_id: Optional[str] = None + mode: str = "structured_longform" + target_band: Optional[str] = None + + +class AuthorWorkCreateRequest(BaseModel): + world_version_id: str + account_id: Optional[str] = None + + +class AuthorWorkListRequest(BaseModel): + account_id: Optional[str] = None + world_version_id: Optional[str] = None + + +class AuthorWorkGenerateRequest(BaseModel): + mode: str = "next" + account_id: Optional[str] = None + + +class AuthorWorkChapterEditRequest(BaseModel): + chapter_title: Optional[str] = None + body: Optional[str] = None + summary: Optional[str] = None + account_id: Optional[str] = None + + +class AuthorWorkDiagnosticsRequest(BaseModel): + account_id: Optional[str] = None + + +class AuthorWorkSubmitRequest(BaseModel): + account_id: Optional[str] = None + + +class AuthorWorkBranchCreateRequest(BaseModel): + source_chapter_index: int + label: Optional[str] = None + steering_directive: Optional[Dict[str, Any]] = None + choice_source: Optional[Any] = None + account_id: Optional[str] = None + + +class AuthorInteractiveScenarioDirectiveRequest(BaseModel): + current_user_intent: Optional[str] = None + summary: Optional[str] = None + impacted_character_ids: list[str] = Field(default_factory=list) + memory_patch_note: Optional[str] = None + affected_arc_id: Optional[str] = None + + +class AuthorInteractiveScenarioRequest(BaseModel): + scenario_id: Optional[str] = None + scenario_kind: str = "mild_steer" + label: str = "" + trigger_chapter: Optional[int] = None + steering_directive: AuthorInteractiveScenarioDirectiveRequest = Field(default_factory=AuthorInteractiveScenarioDirectiveRequest) + + +class AuthorDraftSimulateRequest(BaseModel): + account_id: Optional[str] = None + interactive_scenarios: list[AuthorInteractiveScenarioRequest] = Field(default_factory=list) + include_cross_pack: bool = True + max_chapters: int = Field(default=6, ge=1, le=12) + + +class StrategyBundleExecuteRequest(BaseModel): + campaign_id: Optional[str] = None + account_id: Optional[str] = None + + +class PromiseStateUpdateRequest(BaseModel): + 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 + account_id: Optional[str] = None + + +class ContinuityOverrideUpdateRequest(BaseModel): + chapter_index: int + override_state: str = "" + notes: str = "" + issue_scope: list[str] = Field(default_factory=list) + chapter_task_id: Optional[str] = None + arc_id: Optional[str] = None + volume_id: Optional[str] = None + account_id: Optional[str] = None + + +class TaskBulkApplyRequest(BaseModel): + chapter_indices: list[int] = Field(default_factory=list) + override_state: str = "" + notes: str = "" + issue_scope: list[str] = Field(default_factory=list) + chapter_task_id: Optional[str] = None + arc_id: Optional[str] = None + volume_id: Optional[str] = None + account_id: Optional[str] = None + + class AuthorCommentThreadRequest(BaseModel): revision_id: Optional[str] = None anchor_type: str @@ -103,24 +211,50 @@ class AuthorNotificationPreferenceRequest(BaseModel): def _request_identity(request: Request) -> Dict[str, Optional[str]]: authorization = request.headers.get("Authorization") or "" if authorization.lower().startswith("bearer "): - raw_token = authorization.split(" ", 1)[1].strip() - if raw_token: - try: - resolved = request.app.state.auth_service.resolve_bearer_token(raw_token) - except (PermissionError, KeyError) as exc: - raise HTTPException(status_code=401, detail={"code": "auth_token_invalid", "reason": str(exc)}) from exc - return { - "actor_id": resolved.get("actor_id"), - "actor_role": resolved.get("actor_role"), - "account_id": resolved.get("account_id"), - } + raw_token = request.app.state.auth_service.extract_request_token( + authorization=authorization, + cookies=None, + ) + try: + resolved = request.app.state.auth_service.resolve_bearer_token(raw_token or "") + except (PermissionError, KeyError) as exc: + raise HTTPException(status_code=401, detail={"code": "auth_token_invalid", "reason": str(exc)}) from exc + return { + "actor_id": resolved.get("actor_id"), + "actor_role": resolved.get("actor_role"), + "account_id": resolved.get("account_id"), + "identity_source": "bearer", + } actor_id = request.headers.get(ACTOR_ID_HEADER) actor_role = request.headers.get(ACTOR_ROLE_HEADER) account_id = request.headers.get(ACCOUNT_ID_HEADER) + if actor_id or actor_role or account_id: + return { + "actor_id": actor_id.strip() if actor_id else None, + "actor_role": actor_role.strip() if actor_role else None, + "account_id": account_id.strip() if account_id else None, + "identity_source": "header", + } + raw_token = request.app.state.auth_service.extract_request_token( + authorization=None, + cookies=request.cookies, + ) + if raw_token: + try: + resolved = request.app.state.auth_service.resolve_bearer_token(raw_token) + except (PermissionError, KeyError) as exc: + raise HTTPException(status_code=401, detail={"code": "auth_token_invalid", "reason": str(exc)}) from exc + return { + "actor_id": resolved.get("actor_id"), + "actor_role": resolved.get("actor_role"), + "account_id": resolved.get("account_id"), + "identity_source": "cookie", + } return { "actor_id": actor_id.strip() if actor_id else None, "actor_role": actor_role.strip() if actor_role else None, "account_id": account_id.strip() if account_id else None, + "identity_source": "header" if (actor_id or actor_role or account_id) else None, } @@ -160,6 +294,409 @@ def _resolve_account_value(request: Request, fallback: Optional[str] = None) -> return identity["account_id"] or identity["actor_id"] or fallback +def _resolve_authenticated_account_value(request: Request, fallback: Optional[str] = None) -> Optional[str]: + raw_token = request.app.state.auth_service.extract_request_token( + authorization=request.headers.get("Authorization"), + cookies=request.cookies, + ) + if not raw_token: + return fallback + try: + identity = request.app.state.auth_service.resolve_bearer_token(raw_token) + except (PermissionError, KeyError) as exc: + raise HTTPException(status_code=401, detail={"code": "auth_token_invalid", "reason": str(exc)}) from exc + return identity.get("account_id") or identity.get("actor_id") or fallback + + +def _authenticated_author_identity( + request: Request, + *, + missing_code: str = "author_draft_auth_required", + missing_reason: str = "author_draft_owner_token_required", +) -> Dict[str, Any]: + raw_token = request.app.state.auth_service.extract_request_token( + authorization=request.headers.get("Authorization"), + cookies=request.cookies, + ) + if not raw_token: + raise HTTPException(status_code=401, detail={"code": missing_code, "reason": missing_reason}) + try: + return request.app.state.auth_service.resolve_bearer_token(raw_token) + except (PermissionError, KeyError) as exc: + raise HTTPException(status_code=401, detail={"code": "auth_token_invalid", "reason": str(exc)}) from exc + + +def _author_identity_actor_id(identity: Dict[str, Any]) -> str: + return str(identity.get("actor_id") or "").strip() + + +def _author_identity_account_id(identity: Dict[str, Any]) -> str: + return str(identity.get("account_id") or identity.get("actor_id") or "").strip() + + +def _authenticated_author_actor_context( + request: Request, + *, + missing_code: str = "author_collaboration_session_required", + forbidden_code: str = "author_collaboration_role_forbidden", + allowed_roles: Optional[set[str]] = None, +) -> Dict[str, str]: + raw_token = request.app.state.auth_service.extract_request_token( + authorization=request.headers.get("Authorization"), + cookies=request.cookies, + ) + if not raw_token: + raise HTTPException(status_code=403, detail={"code": missing_code, "reason": missing_code}) + try: + identity = request.app.state.auth_service.resolve_bearer_token(raw_token) + except (PermissionError, KeyError) as exc: + raise HTTPException(status_code=401, detail={"code": "auth_token_invalid", "reason": str(exc)}) from exc + actor_id = _author_identity_actor_id(identity) + actor_role = str(identity.get("actor_role") or "").strip() + account_id = _author_identity_account_id(identity) + if not actor_id: + raise HTTPException(status_code=403, detail={"code": missing_code, "reason": missing_code}) + if allowed_roles is not None and actor_role not in allowed_roles: + raise HTTPException(status_code=403, detail={"code": forbidden_code, "reason": forbidden_code}) + return { + "actor_id": actor_id, + "actor_role": actor_role, + "account_id": account_id, + } + + +def _record_author_audit_log( + request: Request, + *, + actor_id: str, + actor_role: str, + account_id: str, + world_version_id: str, + action_type: str, + customer_visible_payload: Optional[Dict[str, Any]] = None, + internal_payload: Optional[Dict[str, Any]] = None, +) -> None: + audit_service = getattr(request.app.state, "commercial_audit_service", None) + if audit_service is None: + return + try: + audit_service.record_audit_log( + actor_id=actor_id, + actor_role=actor_role, + account_id=account_id, + object_type="author_draft", + object_id=world_version_id, + action_type=action_type, + source_surface="author", + customer_visible_payload=customer_visible_payload or {}, + internal_payload=internal_payload or {}, + ) + except Exception: + return + + +def _author_payload_with_actor( + payload: Dict[str, Any], + actor: Dict[str, str], + *, + actor_field: Optional[str] = "actor_id", + role_field: Optional[str] = "actor_role", + reviewer_field: Optional[str] = None, + recipient_field: Optional[str] = None, + account_field: Optional[str] = None, +) -> Dict[str, Any]: + resolved = dict(payload) + if reviewer_field: + resolved[reviewer_field] = actor["actor_id"] + elif recipient_field: + resolved[recipient_field] = actor["actor_id"] + elif actor_field: + resolved[actor_field] = actor["actor_id"] + if role_field: + resolved[role_field] = actor["actor_role"] + if account_field: + resolved[account_field] = actor["account_id"] + return resolved + + +def _normalized_author_account_id(value: Any) -> Optional[str]: + normalized = str(value or "").strip() + return normalized or None + + +def _ensure_author_account_owner( + request: Request, + *requested_account_ids: Any, + world_version_id: Optional[str] = None, +) -> str: + identity = _authenticated_author_identity( + request, + missing_code="author_account_auth_required", + missing_reason="author_account_token_required", + ) + token_account_id = _author_identity_account_id(identity) + if not token_account_id: + raise HTTPException( + status_code=401, + detail={"code": "author_account_auth_required", "reason": "author_account_required"}, + ) + + candidates = [_normalized_author_account_id(item) for item in requested_account_ids] + if world_version_id: + try: + version = request.app.state.repository.get_world_version(world_version_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + candidates.append(_normalized_author_account_id(version.author_id)) + + for requested_account_id in [item for item in candidates if item]: + if requested_account_id != token_account_id: + raise HTTPException( + status_code=403, + detail={ + "code": "author_account_mismatch", + "reason": "author_account_mismatch", + "token_account_id": token_account_id, + "requested_account_id": requested_account_id, + }, + ) + return token_account_id + + +def _author_payload_worldpack_owner(worldpack: Dict[str, Any]) -> Optional[str]: + manifest = worldpack.get("manifest") + if isinstance(manifest, dict): + return _normalized_author_account_id(manifest.get("author_id")) + return None + + +def _with_author_worldpack_owner(worldpack: Dict[str, Any], account_id: str) -> Dict[str, Any]: + resolved = dict(worldpack) + manifest = dict(resolved.get("manifest") or {}) + manifest["author_id"] = account_id + resolved["manifest"] = manifest + return resolved + + +def _with_author_brief_owner(brief: Dict[str, Any], account_id: str) -> Dict[str, Any]: + return {**brief, "account_id": account_id, "author_id": account_id} + + +def _ensure_author_draft_owner(request: Request, world_version_id: str) -> tuple[Any, str]: + identity = _authenticated_author_identity(request) + token_account_id = _author_identity_account_id(identity) + if not token_account_id: + raise HTTPException(status_code=401, detail={"code": "author_draft_auth_required", "reason": "author_account_required"}) + try: + version = request.app.state.repository.get_world_version(world_version_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + owner_account_id = str(version.author_id or "").strip() + if not owner_account_id: + raise HTTPException(status_code=403, detail={"code": "author_draft_owner_missing", "reason": "author_draft_owner_missing"}) + if token_account_id != owner_account_id: + raise HTTPException( + status_code=403, + detail={ + "code": "author_draft_ownership_mismatch", + "reason": "author_draft_owner_mismatch", + "token_account_id": token_account_id, + "owner_account_id": owner_account_id, + }, + ) + return version, token_account_id + + +def _ensure_author_workflow_account(request: Request, *, account_id: Optional[str], world_version_id: Optional[str]) -> str: + identity = _authenticated_author_identity(request) + token_account_id = _author_identity_account_id(identity) + if not token_account_id: + raise HTTPException(status_code=401, detail={"code": "author_workflow_auth_required", "reason": "author_account_required"}) + if world_version_id: + _version, owner_account_id = _ensure_author_draft_owner(request, world_version_id) + return owner_account_id + requested_account_id = str(account_id or "").strip() + if requested_account_id and requested_account_id != token_account_id: + raise HTTPException( + status_code=403, + detail={ + "code": "author_workflow_account_mismatch", + "reason": "author_workflow_account_mismatch", + "token_account_id": token_account_id, + "requested_account_id": requested_account_id, + }, + ) + return token_account_id + + +def _author_collaboration_participant_ids(request: Request, *, world_version_id: str) -> set[str]: + participant_ids: set[str] = set() + try: + version = request.app.state.repository.get_world_version(world_version_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + owner_id = str(version.author_id or "").strip() + if owner_id: + participant_ids.add(owner_id) + for watcher in request.app.state.repository.list_author_draft_watchers(world_version_id=world_version_id): + watcher_id = str(watcher.get("watcher_id") or "").strip() + if watcher_id: + participant_ids.add(watcher_id) + for approval in request.app.state.repository.list_author_approval_records(world_version_id=world_version_id, status="requested"): + reviewer_id = str(approval.get("reviewer_id") or "").strip() + if reviewer_id: + participant_ids.add(reviewer_id) + for thread in request.app.state.repository.list_author_comment_threads(world_version_id=world_version_id): + for candidate in (thread.get("created_by"), thread.get("assignee_id")): + candidate_id = str(candidate or "").strip() + if candidate_id: + participant_ids.add(candidate_id) + for watcher in request.app.state.repository.list_author_thread_watchers(thread_id=str(thread.get("thread_id") or "")): + watcher_id = str(watcher.get("watcher_id") or "").strip() + if watcher_id: + participant_ids.add(watcher_id) + return participant_ids + + +def _ensure_author_collaboration_object_scope(request: Request, *, world_version_id: str) -> Dict[str, Any]: + identity = _authenticated_author_identity(request) + candidate_ids = { + value + for value in { + _author_identity_actor_id(identity), + _author_identity_account_id(identity), + } + if value + } + participant_ids = _author_collaboration_participant_ids(request, world_version_id=world_version_id) + if candidate_ids & participant_ids: + return identity + raise HTTPException( + status_code=403, + detail={ + "code": "author_collaboration_object_forbidden", + "reason": "author_collaboration_object_forbidden", + }, + ) + + +def _ensure_author_thread_collaboration_scope(request: Request, *, thread_id: str) -> tuple[Dict[str, Any], Dict[str, Any]]: + try: + thread = request.app.state.repository.get_author_comment_thread(thread_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + identity = _ensure_author_collaboration_object_scope(request, world_version_id=str(thread.get("world_version_id") or "")) + return identity, thread + + +def _ensure_author_draft_watcher_mutation_scope(request: Request, *, world_version_id: str, watcher_id: str, add: bool) -> None: + identity = _authenticated_author_identity(request) + token_ids = { + value + for value in { + _author_identity_actor_id(identity), + _author_identity_account_id(identity), + } + if value + } + try: + version = request.app.state.repository.get_world_version(world_version_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + owner_id = str(version.author_id or "").strip() + if owner_id and owner_id in token_ids: + return + target_watcher_id = str(watcher_id or "").strip() + existing_self_watcher = bool( + target_watcher_id + and target_watcher_id in token_ids + and request.app.state.repository.list_author_draft_watchers( + world_version_id=world_version_id, + watcher_id=target_watcher_id, + ) + ) + if not add and existing_self_watcher: + return + raise HTTPException( + status_code=403, + detail={ + "code": "author_collaboration_object_forbidden", + "reason": "author_collaboration_object_forbidden", + }, + ) + + +def _ensure_author_thread_watcher_mutation_scope(request: Request, *, thread_id: str, watcher_id: str, add: bool) -> None: + identity, thread = _ensure_author_thread_collaboration_scope(request, thread_id=thread_id) + token_ids = { + value + for value in { + _author_identity_actor_id(identity), + _author_identity_account_id(identity), + } + if value + } + try: + owner_id = str(request.app.state.repository.get_world_version(str(thread.get("world_version_id") or "")).author_id or "").strip() + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + if owner_id and owner_id in token_ids: + return + target_watcher_id = str(watcher_id or "").strip() + existing_self_watcher = bool( + target_watcher_id + and target_watcher_id in token_ids + and request.app.state.repository.list_author_thread_watchers( + thread_id=thread_id, + watcher_id=target_watcher_id, + ) + ) + if not add and existing_self_watcher: + return + if add and token_ids & _author_collaboration_participant_ids(request, world_version_id=str(thread.get("world_version_id") or "")): + return + raise HTTPException( + status_code=403, + detail={ + "code": "author_collaboration_object_forbidden", + "reason": "author_collaboration_object_forbidden", + }, + ) + + +def ensure_author_collaboration_access(request: Request) -> Optional[Dict[str, Optional[str]]]: + identity = _request_identity(request) + try: + return request.app.state.author_permission_policy.authorize( + actor_id=identity.get("actor_id"), + actor_role=identity.get("actor_role"), + identity_source=identity.get("identity_source"), + method=request.method, + path=request.url.path, + ) + except PermissionError as exc: + reason = str(exc) + raise HTTPException(status_code=403, detail={"code": reason, "reason": reason}) from exc + + +def _ensure_author_work_account(request: Request, expected_account_id: Optional[str], fallback: Optional[str] = None) -> str: + identity = _authenticated_author_identity( + request, + missing_code="author_work_identity_required", + missing_reason="author_account_required", + ) + resolved_account_id = _author_identity_account_id(identity) + if not resolved_account_id: + raise HTTPException(status_code=401, detail={"code": "author_work_identity_required", "reason": "author_account_required"}) + for candidate in ( + _normalized_author_account_id(expected_account_id), + _normalized_author_account_id(fallback), + ): + if candidate and resolved_account_id != candidate: + raise HTTPException(status_code=403, detail={"code": "author_work_forbidden", "reason": "author_work_account_mismatch"}) + return resolved_account_id + + def _execute_collaboration_action(fn): try: return fn() @@ -173,13 +710,22 @@ def _execute_collaboration_action(fn): @router.get("/drafts") def list_drafts(request: Request) -> Dict[str, Any]: + account_id = _resolve_authenticated_account_value(request) drafts = request.app.state.repository.list_world_versions(status="draft") + if account_id: + drafts = [item for item in drafts if str(item.get("author_id") or "").strip() == str(account_id).strip()] + else: + drafts = [] return {"drafts": drafts} @router.post("/drafts") def save_draft(payload: SaveDraftRequest, request: Request) -> Dict[str, Any]: - account_id = _resolve_account_value(request, payload.account_id or payload.worldpack.get("manifest", {}).get("author_id") or "web_author") + account_id = _ensure_author_account_owner( + request, + payload.account_id, + _author_payload_worldpack_owner(payload.worldpack), + ) access = request.app.state.billing_service.access_check_author(account_id=account_id, action_name="save_draft") if not access["allowed"]: raise HTTPException( @@ -189,7 +735,8 @@ def save_draft(payload: SaveDraftRequest, request: Request) -> Dict[str, Any]: **access, }, ) - draft = request.app.state.authoring_service.save_draft(payload.worldpack, change_context=payload.change_context) + worldpack = _with_author_worldpack_owner(payload.worldpack, account_id) + draft = request.app.state.authoring_service.save_draft(worldpack, change_context=payload.change_context) request.app.state.analytics_service.track( "author_draft_saved", reader_id=account_id, @@ -218,8 +765,13 @@ def author_access( account_id: Optional[str] = None, world_version_id: Optional[str] = None, ) -> Dict[str, Any]: + resolved_account_id = _ensure_author_account_owner( + request, + account_id, + world_version_id=world_version_id, + ) return request.app.state.billing_service.author_access_snapshot( - account_id=_resolve_account_value(request, account_id), + account_id=resolved_account_id, world_version_id=world_version_id, ) @@ -230,14 +782,20 @@ def author_workflow( account_id: Optional[str] = None, world_version_id: Optional[str] = None, ) -> Dict[str, Any]: + resolved_account_id = _ensure_author_workflow_account( + request, + account_id=account_id, + world_version_id=world_version_id, + ) return request.app.state.authoring_service.workflow_summary( - account_id=_resolve_account_value(request, account_id), + account_id=resolved_account_id, world_version_id=world_version_id, ) @router.get("/drafts/{world_version_id}/collaboration") def collaboration_summary(world_version_id: str, request: Request) -> Dict[str, Any]: + _ensure_author_collaboration_object_scope(request, world_version_id=world_version_id) return request.app.state.author_collaboration_service.collaboration_summary(world_version_id=world_version_id) @@ -253,10 +811,15 @@ def reviewer_inbox( cursor: Optional[str] = None, q: Optional[str] = None, ) -> Dict[str, Any]: - resolved_reviewer_id = _resolve_identity_value(request, reviewer_id) + actor = _authenticated_author_actor_context( + request, + missing_code="author_review_session_required", + forbidden_code="author_review_role_forbidden", + allowed_roles={"reviewer", "ops", "admin", "editor"}, + ) return _execute_collaboration_action( lambda: request.app.state.author_collaboration_service.reviewer_inbox( - reviewer_id=str(resolved_reviewer_id or ""), + reviewer_id=actor["actor_id"], limit=limit, world_version_id=world_version_id, status_filter=status_filter, @@ -274,6 +837,7 @@ def create_comment_thread( payload: AuthorCommentThreadRequest, request: Request, ) -> Dict[str, Any]: + _ensure_author_draft_owner(request, world_version_id) return _execute_collaboration_action( lambda: request.app.state.author_collaboration_service.create_comment_thread( world_version_id=world_version_id, @@ -288,6 +852,7 @@ def reply_comment_thread( payload: AuthorCommentReplyRequest, request: Request, ) -> Dict[str, Any]: + _ensure_author_thread_collaboration_scope(request, thread_id=thread_id) return _execute_collaboration_action( lambda: request.app.state.author_collaboration_service.reply_to_thread( thread_id, @@ -302,6 +867,7 @@ def update_comment_thread_status( payload: AuthorCommentStatusRequest, request: Request, ) -> Dict[str, Any]: + _ensure_author_thread_collaboration_scope(request, thread_id=thread_id) return _execute_collaboration_action( lambda: request.app.state.author_collaboration_service.update_thread_status( thread_id, @@ -316,12 +882,33 @@ def request_author_approval( payload: AuthorApprovalRequest, request: Request, ) -> Dict[str, Any]: - return _execute_collaboration_action( + version, account_id = _ensure_author_draft_owner(request, world_version_id) + result = _execute_collaboration_action( lambda: request.app.state.author_collaboration_service.request_approval( world_version_id=world_version_id, payload=_apply_identity(request, payload.model_dump()), ) ) + approval = dict(result.get("approval") or {}) + _record_author_audit_log( + request, + actor_id=account_id, + actor_role="author", + account_id=account_id, + world_version_id=world_version_id, + action_type="author_approval_requested", + customer_visible_payload={ + "world_id": version.world_id, + "world_version_id": world_version_id, + "approval_status": approval.get("status"), + "reviewer_id": approval.get("reviewer_id"), + }, + internal_payload={ + "approval_id": approval.get("approval_id"), + "reason": approval.get("reason"), + }, + ) + return result @router.post("/drafts/{world_version_id}/approval/decision") @@ -330,12 +917,52 @@ def decide_author_approval( payload: AuthorApprovalDecisionRequest, request: Request, ) -> Dict[str, Any]: - return _execute_collaboration_action( + _ensure_author_collaboration_object_scope(request, world_version_id=world_version_id) + actor = _authenticated_author_actor_context( + request, + missing_code="author_review_session_required", + forbidden_code="author_review_role_forbidden", + allowed_roles={"reviewer", "ops", "admin", "editor"}, + ) + result = _execute_collaboration_action( lambda: request.app.state.author_collaboration_service.approval_decision( world_version_id=world_version_id, - payload=_apply_identity(request, payload.model_dump(), actor_field=None, role_field=None, reviewer_field="reviewer_id"), + payload=_author_payload_with_actor( + payload.model_dump(), + actor, + actor_field=None, + role_field=None, + reviewer_field="reviewer_id", + ), ) ) + approval = dict(result.get("approval") or {}) + try: + version = request.app.state.repository.get_world_version(world_version_id) + account_id = str(version.author_id or "").strip() + world_id = version.world_id + except KeyError: + account_id = str(approval.get("reviewer_id") or actor["account_id"] or actor["actor_id"]) + world_id = "" + _record_author_audit_log( + request, + actor_id=actor["actor_id"], + actor_role=actor["actor_role"], + account_id=account_id, + world_version_id=world_version_id, + action_type="author_approval_decision", + customer_visible_payload={ + "world_id": world_id, + "world_version_id": world_version_id, + "approval_status": approval.get("status"), + "reviewer_id": actor["actor_id"], + }, + internal_payload={ + "approval_id": approval.get("approval_id"), + "reason": approval.get("reason"), + }, + ) + return result @router.post("/notifications/{notification_id}/status") @@ -344,10 +971,17 @@ def update_author_notification_status( payload: AuthorNotificationStatusRequest, request: Request, ) -> Dict[str, Any]: + actor = _authenticated_author_actor_context(request, allowed_roles={"author", "reviewer", "ops", "admin", "editor"}) return _execute_collaboration_action( lambda: request.app.state.author_collaboration_service.update_notification_status( notification_id, - payload=_apply_identity(request, payload.model_dump(), actor_field=None, role_field=None, recipient_field="recipient_id"), + payload=_author_payload_with_actor( + payload.model_dump(), + actor, + actor_field=None, + role_field=None, + recipient_field="recipient_id", + ), ) ) @@ -357,9 +991,16 @@ def bulk_update_author_notification_status( payload: AuthorNotificationBulkStatusRequest, request: Request, ) -> Dict[str, Any]: + actor = _authenticated_author_actor_context(request, allowed_roles={"author", "reviewer", "ops", "admin", "editor"}) return _execute_collaboration_action( lambda: request.app.state.author_collaboration_service.bulk_update_notification_status( - _apply_identity(request, payload.model_dump(), actor_field=None, role_field=None, recipient_field="recipient_id"), + _author_payload_with_actor( + payload.model_dump(), + actor, + actor_field=None, + role_field=None, + recipient_field="recipient_id", + ), ) ) @@ -370,6 +1011,8 @@ def add_author_thread_watcher( payload: AuthorThreadWatcherRequest, request: Request, ) -> Dict[str, Any]: + watcher_id = str(payload.watcher_id or payload.actor_id or "").strip() + _ensure_author_thread_watcher_mutation_scope(request, thread_id=thread_id, watcher_id=watcher_id, add=True) return _execute_collaboration_action( lambda: request.app.state.author_collaboration_service.add_thread_watcher( thread_id, @@ -385,6 +1028,7 @@ def remove_author_thread_watcher( payload: AuthorThreadWatcherRequest, request: Request, ) -> Dict[str, Any]: + _ensure_author_thread_watcher_mutation_scope(request, thread_id=thread_id, watcher_id=watcher_id, add=False) return _execute_collaboration_action( lambda: request.app.state.author_collaboration_service.remove_thread_watcher( thread_id, @@ -400,6 +1044,8 @@ def add_author_draft_watcher( payload: AuthorDraftWatcherRequest, request: Request, ) -> Dict[str, Any]: + watcher_id = str(payload.watcher_id or payload.actor_id or "").strip() + _ensure_author_draft_watcher_mutation_scope(request, world_version_id=world_version_id, watcher_id=watcher_id, add=True) return _execute_collaboration_action( lambda: request.app.state.author_collaboration_service.add_draft_watcher( world_version_id, @@ -415,6 +1061,7 @@ def remove_author_draft_watcher( payload: AuthorDraftWatcherRequest, request: Request, ) -> Dict[str, Any]: + _ensure_author_draft_watcher_mutation_scope(request, world_version_id=world_version_id, watcher_id=watcher_id, add=False) return _execute_collaboration_action( lambda: request.app.state.author_collaboration_service.remove_draft_watcher( world_version_id, @@ -429,9 +1076,9 @@ def author_notification_preferences( request: Request, actor_id: Optional[str] = None, ) -> Dict[str, Any]: - resolved_actor_id = _resolve_identity_value(request, actor_id) + actor = _authenticated_author_actor_context(request, allowed_roles={"author", "reviewer", "ops", "admin", "editor"}) return _execute_collaboration_action( - lambda: request.app.state.author_collaboration_service.notification_preferences(str(resolved_actor_id or "")) + lambda: request.app.state.author_collaboration_service.notification_preferences(actor["actor_id"]) ) @@ -440,18 +1087,21 @@ def update_author_notification_preference( payload: AuthorNotificationPreferenceRequest, request: Request, ) -> Dict[str, Any]: + actor = _authenticated_author_actor_context(request, allowed_roles={"author", "reviewer", "ops", "admin", "editor"}) return _execute_collaboration_action( lambda: request.app.state.author_collaboration_service.update_notification_preference( - _apply_identity(request, payload.model_dump()), + _author_payload_with_actor(payload.model_dump(), actor), ) ) @router.post("/drafts/from-brief") def create_draft_from_brief(payload: AuthorBriefRequest, request: Request) -> Dict[str, Any]: - account_id = _resolve_account_value( + account_id = _ensure_author_account_owner( request, - payload.account_id or payload.brief.get("account_id") or payload.brief.get("author_id") or "web_author", + payload.account_id, + payload.brief.get("account_id"), + payload.brief.get("author_id"), ) access = request.app.state.billing_service.access_check_author(account_id=account_id, action_name="draft_from_brief") if not access["allowed"]: @@ -465,9 +1115,7 @@ def create_draft_from_brief(payload: AuthorBriefRequest, request: Request) -> Di "reason": access["reason"], }, ) - draft = request.app.state.authoring_service.create_draft_from_brief( - {**payload.brief, "account_id": account_id, "author_id": payload.brief.get("author_id") or account_id} - ) + draft = request.app.state.authoring_service.create_draft_from_brief(_with_author_brief_owner(payload.brief, account_id)) wallet = request.app.state.billing_service.consume_studio_credits( account_id=access["account_id"], amount=request.app.state.monetization_service.metering_rules()["author_from_brief_studio_credits"], @@ -513,17 +1161,245 @@ def create_draft_from_brief(payload: AuthorBriefRequest, request: Request) -> Di @router.get("/drafts/{world_version_id}") def get_draft(world_version_id: str, request: Request) -> Dict[str, Any]: + _ensure_author_draft_owner(request, world_version_id) + return request.app.state.authoring_service.get_draft(world_version_id) + + +@router.get("/works") +def list_author_works( + request: Request, + account_id: Optional[str] = None, + world_version_id: Optional[str] = None, +) -> Dict[str, Any]: + resolved_account_id = _ensure_author_work_account(request, account_id, account_id) + return request.app.state.author_work_service.list_works( + account_id=resolved_account_id, + world_version_id=world_version_id, + ) + + +@router.post("/works") +def create_author_work(payload: AuthorWorkCreateRequest, request: Request) -> Dict[str, Any]: + version = request.app.state.repository.get_world_version(payload.world_version_id) + account_id = _ensure_author_work_account(request, version.author_id, payload.account_id or version.author_id) + access = request.app.state.billing_service.access_check_author(account_id=account_id, action_name="update_draft") + if not access["allowed"]: + raise HTTPException(status_code=402, detail={"code": "author_entitlement_required", **access}) + return request.app.state.author_work_service.create_work( + world_version_id=payload.world_version_id, + account_id=account_id or version.author_id, + ) + + +@router.get("/works/{work_id}") +def get_author_work(work_id: str, request: Request) -> Dict[str, Any]: + try: + work = request.app.state.repository.get_author_work(work_id) + _ensure_author_work_account(request, work.get("account_id"), work.get("account_id")) + return request.app.state.author_work_service.get_work(work_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + + +@router.get("/works/{work_id}/export") +def export_author_work(work_id: str, request: Request, format: str = "nosbook", route: str = "active") -> JSONResponse: + if str(format or "").strip() != "nosbook": + raise HTTPException(status_code=400, detail={"code": "unsupported_author_work_export_format", "reason": format}) + try: + work = request.app.state.repository.get_author_work(work_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + _ensure_author_work_account(request, work.get("account_id"), work.get("account_id")) + payload = request.app.state.author_work_service.export_work_nosbook(work_id=work_id, route=route) + return JSONResponse( + content=payload, + media_type="application/vnd.narrativeos.nosbook+json", + headers={"Content-Disposition": f"attachment; filename=\"{payload.get('filename') or 'narrativeos-work.nosbook'}\""}, + ) + + +@router.delete("/works/{work_id}") +def delete_author_work(work_id: str, request: Request, account_id: Optional[str] = None) -> Dict[str, Any]: + try: + work = request.app.state.repository.get_author_work(work_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + _ensure_author_work_account(request, work.get("account_id"), account_id or work.get("account_id")) + return request.app.state.author_work_service.delete_work_family(work_id=work_id) + + +@router.get("/works/{work_id}/chapters/{chapter_index}") +def get_author_work_chapter(work_id: str, chapter_index: int, request: Request) -> Dict[str, Any]: try: - return request.app.state.authoring_service.get_draft(world_version_id) + work = request.app.state.repository.get_author_work(work_id) + _ensure_author_work_account(request, work.get("account_id"), work.get("account_id")) + return request.app.state.author_work_service.get_work_chapter(work_id=work_id, chapter_index=chapter_index) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) +@router.post("/works/{work_id}/chapters/generate") +def generate_author_work_chapters(work_id: str, payload: AuthorWorkGenerateRequest, request: Request) -> Dict[str, Any]: + try: + work = request.app.state.repository.get_author_work(work_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + account_id = _ensure_author_work_account(request, work.get("account_id"), payload.account_id or work.get("account_id")) + access = request.app.state.billing_service.access_check_author(account_id=account_id, action_name="simulate") + if not access["allowed"]: + raise HTTPException( + status_code=402, + detail={ + "code": "author_entitlement_required", + "required_tier": access["required_tier"], + "wallet_type": access["wallet_type"], + "balance": access["balance"], + "reason": access["reason"], + }, + ) + try: + result = request.app.state.author_work_service.generate_chapters(work_id=work_id, mode=payload.mode) + except ChapterQualityGuardError as exc: + raise HTTPException(status_code=400, detail={"code": exc.quality_gate.get("code"), "quality_gate": exc.quality_gate}) from exc + request.app.state.billing_service.consume_studio_credits( + account_id=access["account_id"], + amount=request.app.state.monetization_service.metering_rules()["author_simulate_studio_credits"], + ) + return result + + +@router.post("/works/{work_id}/chapters/{chapter_index}/edit") +def edit_author_work_chapter( + work_id: str, + chapter_index: int, + payload: AuthorWorkChapterEditRequest, + request: Request, +) -> Dict[str, Any]: + try: + work = request.app.state.repository.get_author_work(work_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + account_id = _ensure_author_work_account(request, work.get("account_id"), payload.account_id or work.get("account_id")) + access = request.app.state.billing_service.access_check_author(account_id=account_id, action_name="update_draft") + if not access["allowed"]: + raise HTTPException(status_code=402, detail={"code": "author_entitlement_required", **access}) + try: + return request.app.state.author_work_service.edit_chapter( + work_id=work_id, + chapter_index=chapter_index, + title=payload.chapter_title, + body=payload.body, + summary=payload.summary, + ) + except ChapterQualityGuardError as exc: + raise HTTPException(status_code=400, detail={"code": exc.quality_gate.get("code"), "quality_gate": exc.quality_gate}) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.post("/works/{work_id}/diagnostics/run") +def run_author_work_diagnostics(work_id: str, payload: AuthorWorkDiagnosticsRequest, request: Request) -> Dict[str, Any]: + try: + work = request.app.state.repository.get_author_work(work_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + account_id = _ensure_author_work_account(request, work.get("account_id"), payload.account_id or work.get("account_id")) + access = request.app.state.billing_service.access_check_author(account_id=account_id, action_name="simulate") + if not access["allowed"]: + raise HTTPException( + status_code=402, + detail={ + "code": "author_entitlement_required", + "required_tier": access["required_tier"], + "wallet_type": access["wallet_type"], + "balance": access["balance"], + "reason": access["reason"], + }, + ) + result = request.app.state.author_work_service.run_diagnostics(work_id=work_id) + request.app.state.billing_service.consume_studio_credits( + account_id=access["account_id"], + amount=request.app.state.monetization_service.metering_rules()["author_simulate_studio_credits"], + ) + return result + + +@router.post("/works/{work_id}/submit") +def submit_author_work(work_id: str, payload: AuthorWorkSubmitRequest, request: Request) -> Dict[str, Any]: + try: + work = request.app.state.repository.get_author_work(work_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + account_id = _ensure_author_work_account(request, work.get("account_id"), payload.account_id or work.get("account_id")) + access = request.app.state.billing_service.access_check_author(account_id=account_id, action_name="submit_draft") + if not access["allowed"]: + raise HTTPException(status_code=402, detail={"code": "author_entitlement_required", **access}) + try: + return request.app.state.author_work_service.submit_work(work_id=work_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.get("/works/{work_id}/branches") +def list_author_work_branches(work_id: str, request: Request) -> Dict[str, Any]: + try: + work = request.app.state.repository.get_author_work(work_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + _ensure_author_work_account(request, work.get("account_id"), work.get("account_id")) + return request.app.state.author_work_service.list_branches(work_id=work_id) + + +@router.post("/works/{work_id}/branches") +def create_author_work_branch(work_id: str, payload: AuthorWorkBranchCreateRequest, request: Request) -> Dict[str, Any]: + try: + work = request.app.state.repository.get_author_work(work_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + account_id = _ensure_author_work_account(request, work.get("account_id"), payload.account_id or work.get("account_id")) + access = request.app.state.billing_service.access_check_author(account_id=account_id, action_name="simulate") + if not access["allowed"]: + raise HTTPException( + status_code=402, + detail={ + "code": "author_entitlement_required", + "required_tier": access["required_tier"], + "wallet_type": access["wallet_type"], + "balance": access["balance"], + "reason": access["reason"], + }, + ) + try: + return request.app.state.author_work_service.create_branch( + work_id=work_id, + source_chapter_index=payload.source_chapter_index, + label=payload.label, + steering_directive=payload.steering_directive, + choice_source=payload.choice_source, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.post("/works/{work_id}/activate-line") +def activate_author_work_line(work_id: str, payload: AuthorAccountRequest, request: Request) -> Dict[str, Any]: + try: + work = request.app.state.repository.get_author_work(work_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + _ensure_author_work_account(request, work.get("account_id"), payload.account_id or work.get("account_id")) + return request.app.state.author_work_service.activate_branch(work_id=work_id) + + @router.put("/drafts/{world_version_id}") def update_draft(world_version_id: str, payload: SaveDraftRequest, request: Request) -> Dict[str, Any]: try: - version = request.app.state.repository.get_world_version(world_version_id) - account_id = _resolve_account_value(request, payload.account_id or version.author_id) + _version, account_id = _ensure_author_draft_owner(request, world_version_id) + _ensure_author_account_owner( + request, + payload.account_id, + _author_payload_worldpack_owner(payload.worldpack), + ) access = request.app.state.billing_service.access_check_author(account_id=account_id, action_name="update_draft") if not access["allowed"]: raise HTTPException( @@ -533,7 +1409,8 @@ def update_draft(world_version_id: str, payload: SaveDraftRequest, request: Requ **access, }, ) - draft = request.app.state.authoring_service.update_draft(world_version_id, payload.worldpack, change_context=payload.change_context) + worldpack = _with_author_worldpack_owner(payload.worldpack, account_id) + draft = request.app.state.authoring_service.update_draft(world_version_id, worldpack, change_context=payload.change_context) request.app.state.analytics_service.track( "author_draft_updated", reader_id=account_id, @@ -553,9 +1430,240 @@ def update_draft(world_version_id: str, payload: SaveDraftRequest, request: Requ raise HTTPException(status_code=404, detail=str(exc)) +@router.post("/drafts/{world_version_id}/longform-bootstrap") +def bootstrap_longform_workbench(world_version_id: str, payload: AuthorLongformBootstrapRequest, request: Request) -> Dict[str, Any]: + try: + _version, account_id = _ensure_author_draft_owner(request, world_version_id) + access = request.app.state.billing_service.access_check_author(account_id=account_id, action_name="update_draft") + if not access["allowed"]: + raise HTTPException( + status_code=402, + detail={ + "code": "author_entitlement_required", + **access, + }, + ) + draft = request.app.state.authoring_service.bootstrap_longform_workbench( + world_version_id, + mode=payload.mode, + target_band=payload.target_band, + ) + request.app.state.analytics_service.track( + "author_longform_workbench_bootstrapped", + reader_id=account_id, + account_id=account_id, + world_id=draft.get("world_id"), + world_version_id=world_version_id, + access_tier=access.get("tier_id"), + payload_json={ + "mode": payload.mode, + "target_band": payload.target_band, + "wallet_type": access.get("wallet_type"), + "subscription_status": access.get("subscription_status"), + }, + ) + return draft + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + + +@router.post("/drafts/{world_version_id}/promise-state") +def update_promise_state(world_version_id: str, payload: PromiseStateUpdateRequest, request: Request) -> Dict[str, Any]: + try: + _version, account_id = _ensure_author_draft_owner(request, world_version_id) + access = request.app.state.billing_service.access_check_author(account_id=account_id, action_name="update_draft") + if not access["allowed"]: + raise HTTPException( + status_code=402, + detail={ + "code": "author_entitlement_required", + **access, + }, + ) + draft = request.app.state.authoring_service.update_promise_state( + world_version_id, + promise_id=payload.promise_id, + editor_state=payload.editor_state, + notes=payload.notes, + chapter_index=payload.chapter_index, + chapter_task_id=payload.chapter_task_id, + arc_id=payload.arc_id, + volume_id=payload.volume_id, + ) + request.app.state.analytics_service.track( + "author_promise_state_updated", + reader_id=account_id, + account_id=account_id, + world_id=draft.get("world_id"), + world_version_id=world_version_id, + access_tier=access.get("tier_id"), + payload_json={ + "promise_id": payload.promise_id, + "editor_state": payload.editor_state, + "wallet_type": access.get("wallet_type"), + "subscription_status": access.get("subscription_status"), + }, + ) + return draft + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + + +@router.post("/drafts/{world_version_id}/continuity-override") +def update_continuity_override(world_version_id: str, payload: ContinuityOverrideUpdateRequest, request: Request) -> Dict[str, Any]: + try: + _version, account_id = _ensure_author_draft_owner(request, world_version_id) + access = request.app.state.billing_service.access_check_author(account_id=account_id, action_name="update_draft") + if not access["allowed"]: + raise HTTPException( + status_code=402, + detail={ + "code": "author_entitlement_required", + **access, + }, + ) + draft = request.app.state.authoring_service.update_continuity_override( + world_version_id, + chapter_index=payload.chapter_index, + override_state=payload.override_state, + notes=payload.notes, + issue_scope=payload.issue_scope, + chapter_task_id=payload.chapter_task_id, + arc_id=payload.arc_id, + volume_id=payload.volume_id, + ) + request.app.state.analytics_service.track( + "author_continuity_override_updated", + reader_id=account_id, + account_id=account_id, + world_id=draft.get("world_id"), + world_version_id=world_version_id, + access_tier=access.get("tier_id"), + payload_json={ + "chapter_index": payload.chapter_index, + "override_state": payload.override_state, + "issue_scope": payload.issue_scope, + "wallet_type": access.get("wallet_type"), + "subscription_status": access.get("subscription_status"), + }, + ) + return draft + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + + +@router.post("/drafts/{world_version_id}/task-bulk-apply") +def bulk_apply_task_to_simulation(world_version_id: str, payload: TaskBulkApplyRequest, request: Request) -> Dict[str, Any]: + try: + _version, account_id = _ensure_author_draft_owner(request, world_version_id) + access = request.app.state.billing_service.access_check_author(account_id=account_id, action_name="update_draft") + if not access["allowed"]: + raise HTTPException( + status_code=402, + detail={ + "code": "author_entitlement_required", + **access, + }, + ) + draft = request.app.state.authoring_service.bulk_apply_task_continuity_override( + world_version_id, + chapter_indices=payload.chapter_indices, + override_state=payload.override_state, + notes=payload.notes, + issue_scope=payload.issue_scope, + chapter_task_id=payload.chapter_task_id, + arc_id=payload.arc_id, + volume_id=payload.volume_id, + ) + request.app.state.analytics_service.track( + "author_task_bulk_apply", + reader_id=account_id, + account_id=account_id, + world_id=draft.get("world_id"), + world_version_id=world_version_id, + access_tier=access.get("tier_id"), + payload_json={ + "chapter_count": len(payload.chapter_indices), + "override_state": payload.override_state, + "chapter_task_id": payload.chapter_task_id, + "wallet_type": access.get("wallet_type"), + "subscription_status": access.get("subscription_status"), + }, + ) + return draft + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + + +@router.post("/drafts/{world_version_id}/strategy-bundles/execute") +def execute_content_quality_strategy_bundle( + world_version_id: str, + payload: StrategyBundleExecuteRequest, + request: Request, +) -> Dict[str, Any]: + try: + _version, account_id = _ensure_author_draft_owner(request, world_version_id) + access = request.app.state.billing_service.access_check_author(account_id=account_id, action_name="simulate") + if not access["allowed"]: + raise HTTPException( + status_code=402, + detail={ + "code": "author_entitlement_required", + "required_tier": access["required_tier"], + "wallet_type": access["wallet_type"], + "balance": access["balance"], + "reason": access["reason"], + }, + ) + draft = request.app.state.authoring_service.execute_content_quality_strategy_bundle( + world_version_id, + campaign_id=payload.campaign_id, + ) + wallet = request.app.state.billing_service.consume_studio_credits( + account_id=access["account_id"], + amount=request.app.state.monetization_service.metering_rules()["author_simulate_studio_credits"], + ) + request.app.state.billing_service.meter_action( + surface="author", + action_name="simulate", + account_id=access["account_id"], + reader_id=access["account_id"], + world_version_id=world_version_id, + access=access, + provider="internal", + estimated_cost=0.0, + ) + latest_execution = dict(draft.get("latest_strategy_bundle_execution") or {}) + request.app.state.analytics_service.track( + "author_strategy_bundle_executed", + reader_id=access["account_id"], + account_id=access["account_id"], + world_id=draft.get("world_id"), + world_version_id=world_version_id, + access_tier=access.get("tier_id"), + payload_json={ + "campaign_id": payload.campaign_id, + "strategy_bundle_id": latest_execution.get("strategy_bundle_id"), + "stop_decision": dict(latest_execution.get("stop_decision") or {}).get("decision"), + "wallet_type": access.get("wallet_type"), + "wallet_balance": wallet.get("balance"), + "subscription_status": access.get("subscription_status"), + }, + ) + return draft + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + @router.post("/drafts/validate") def validate_draft(payload: SaveDraftRequest, request: Request) -> Dict[str, Any]: - account_id = _resolve_account_value(request, payload.account_id or payload.worldpack.get("manifest", {}).get("author_id") or "web_author") + account_id = _ensure_author_account_owner( + request, + payload.account_id, + _author_payload_worldpack_owner(payload.worldpack), + ) access = request.app.state.billing_service.access_check_author(account_id=account_id, action_name="validate_draft") if not access["allowed"]: raise HTTPException( @@ -565,7 +1673,8 @@ def validate_draft(payload: SaveDraftRequest, request: Request) -> Dict[str, Any **access, }, ) - validation = request.app.state.world_registry.validate_worldpack(payload.worldpack) + worldpack = _with_author_worldpack_owner(payload.worldpack, account_id) + validation = request.app.state.world_registry.validate_worldpack(worldpack) request.app.state.analytics_service.track( "author_draft_validated", reader_id=account_id, @@ -587,10 +1696,15 @@ def validate_draft(payload: SaveDraftRequest, request: Request) -> Dict[str, Any @router.post("/drafts/{world_version_id}/simulate") -def simulate_draft(world_version_id: str, request: Request, account_id: Optional[str] = None) -> Dict[str, Any]: +def simulate_draft( + world_version_id: str, + request: Request, + payload: Optional[AuthorDraftSimulateRequest] = None, + account_id: Optional[str] = None, + summary_only: bool = False, +) -> Dict[str, Any]: try: - version = request.app.state.repository.get_world_version(world_version_id) - resolved_account_id = _resolve_account_value(request, account_id or version.author_id) + version, resolved_account_id = _ensure_author_draft_owner(request, world_version_id) access = request.app.state.billing_service.access_check_author(account_id=resolved_account_id, action_name="simulate") if not access["allowed"]: raise HTTPException( @@ -603,7 +1717,70 @@ def simulate_draft(world_version_id: str, request: Request, account_id: Optional "reason": access["reason"], }, ) - report = request.app.state.authoring_service.run_simulation_for_world_version(world_version_id) + if summary_only: + workflow = request.app.state.authoring_service.workflow_summary( + account_id=resolved_account_id, + world_version_id=world_version_id, + ) + draft = request.app.state.authoring_service.get_draft(world_version_id) + simulation_summary = dict(workflow.get("simulation_summary") or {}) + latest_outcome = dict(draft.get("latest_repair_loop_outcome") or {}) + latest_issues = list(draft.get("latest_quality_issues") or draft.get("issues") or []) + request.app.state.analytics_service.track( + "author_draft_simulation_summary_viewed", + reader_id=access["account_id"], + account_id=access["account_id"], + world_id=version.world_id, + world_version_id=world_version_id, + access_tier=access.get("tier_id"), + payload_json={ + "summary_only": True, + "simulation_available": bool(simulation_summary.get("available")), + "completed_chapters": simulation_summary.get("completed_chapters"), + "pass_rate": simulation_summary.get("pass_rate"), + "wallet_type": access.get("wallet_type"), + "subscription_status": access.get("subscription_status"), + }, + ) + return { + "status": "simulated", + "summary_only": True, + "simulation_executed": False, + "serverless_safe": True, + "world_version_id": world_version_id, + "stage": workflow.get("stage"), + "recommended_action": workflow.get("recommended_action"), + "completed_chapters": simulation_summary.get("completed_chapters", 0), + "pass_rate": simulation_summary.get("pass_rate", 0.0), + "issue_count": len(latest_issues), + "simulation_summary": simulation_summary, + "latest_repair_loop_outcome": { + "ready_for_validation": latest_outcome.get("ready_for_validation"), + "severity_trend": latest_outcome.get("severity_trend"), + "issue_count_delta": latest_outcome.get("issue_count_delta"), + }, + "access": { + "allowed": True, + "tier_id": access.get("tier_id"), + "wallet_type": access.get("wallet_type"), + "subscription_status": access.get("subscription_status"), + }, + } + interactive_scenarios = [ + { + **item.model_dump(exclude_none=True), + "steering_directive": item.steering_directive.model_dump(exclude_none=True), + } + for item in (payload.interactive_scenarios if payload else []) + ] + include_cross_pack = bool(payload.include_cross_pack) if payload else True + max_chapters = int(payload.max_chapters if payload else 6) + report = request.app.state.authoring_service.run_simulation_for_world_version( + world_version_id, + include_cross_pack=include_cross_pack, + max_chapters=max_chapters, + interactive_scenarios=interactive_scenarios or None, + ) wallet = request.app.state.billing_service.consume_studio_credits( account_id=access["account_id"], amount=request.app.state.monetization_service.metering_rules()["author_simulate_studio_credits"], @@ -653,8 +1830,7 @@ def simulate_draft(world_version_id: str, request: Request, account_id: Optional @router.post("/drafts/{world_version_id}/submit") def submit_draft(world_version_id: str, request: Request, account_id: Optional[str] = None) -> Dict[str, Any]: try: - version = request.app.state.repository.get_world_version(world_version_id) - resolved_account_id = _resolve_account_value(request, account_id or version.author_id) + version, resolved_account_id = _ensure_author_draft_owner(request, world_version_id) access = request.app.state.billing_service.access_check_author(account_id=resolved_account_id, action_name="submit_draft") if not access["allowed"]: raise HTTPException( @@ -678,6 +1854,24 @@ def submit_draft(world_version_id: str, request: Request, account_id: Optional[s "subscription_status": access.get("subscription_status"), }, ) + _record_author_audit_log( + request, + actor_id=resolved_account_id, + actor_role="author", + account_id=resolved_account_id, + world_version_id=world_version_id, + action_type="author_draft_submitted", + customer_visible_payload={ + "world_id": version.world_id, + "world_version_id": world_version_id, + "status": result.get("status"), + }, + internal_payload={ + "access_tier": access.get("tier_id"), + "wallet_type": access.get("wallet_type"), + "subscription_status": access.get("subscription_status"), + }, + ) return result except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) diff --git a/src/narrativeos/api/billing_provider.py b/src/narrativeos/api/billing_provider.py new file mode 100644 index 0000000..89a2f18 --- /dev/null +++ b/src/narrativeos/api/billing_provider.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Any, Dict + +from fastapi import APIRouter, HTTPException, Request + + +router = APIRouter(prefix="/v1/billing", tags=["billing"]) + + +@router.post("/stripe/webhook") +async def stripe_billing_webhook(request: Request) -> Dict[str, Any]: + raw_body = await request.body() + signature = request.headers.get("Stripe-Signature") or "" + try: + return request.app.state.stripe_invoicing_service.ingest_stripe_webhook( + raw_body=raw_body, + signature=signature, + ) + except ValueError as exc: + reason = str(exc) + status_code = 400 + if reason in {"stripe_webhook_not_configured", "stripe_sdk_missing"}: + status_code = 503 + raise HTTPException(status_code=status_code, detail={"code": "stripe_billing_webhook_failed", "reason": reason}) from exc diff --git a/src/narrativeos/api/customer.py b/src/narrativeos/api/customer.py new file mode 100644 index 0000000..ce9b1c9 --- /dev/null +++ b/src/narrativeos/api/customer.py @@ -0,0 +1,375 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from ..services.customer_accounts import COMMERCIAL_CUSTOMER_ROLES + + +router = APIRouter(prefix="/v1/customer", tags=["customer"]) + + +class CampaignCreateRequest(BaseModel): + campaign_id: Optional[str] = None + title: str + target_icp_vertical: str + cta_text: str + disclosure_text: str + selected_channels: list[str] = [] + selected_partner_refs: list[str] = [] + proof_points: list[str] = [] + proof_source_urls: list[str] = [] + proof_artifact_refs: list[str] = [] + + +class CampaignSubmitRequest(BaseModel): + pass + + +class DisputeCreateRequest(BaseModel): + campaign_id: Optional[str] = None + invoice_preview_id: Optional[str] = None + billable_event_id: Optional[str] = None + quality_event_id: Optional[str] = None + trace_id: Optional[str] = None + dispute_reason_code: str + note: Optional[str] = None + requested_amount_usd: Optional[float] = None + + +class SupportCaseCreateRequest(BaseModel): + campaign_id: Optional[str] = None + invoice_preview_id: Optional[str] = None + billable_event_id: Optional[str] = None + quality_event_id: Optional[str] = None + trace_id: Optional[str] = None + case_type: str = "general" + subject: str + description: str + priority: str = "medium" + + +class DataDeletionRequestPayload(BaseModel): + scope: str = "customer_account" + requested_payload: Dict[str, Any] = {} + + +def _customer_identity(request: Request) -> Dict[str, Any]: + raw_token = request.app.state.auth_service.extract_request_token( + authorization=request.headers.get("Authorization"), + cookies=request.cookies, + ) + if not raw_token: + raise HTTPException(status_code=401, detail={"code": "customer_auth_missing"}) + try: + identity = request.app.state.auth_service.resolve_bearer_token(raw_token) + except (PermissionError, KeyError) as exc: + raise HTTPException(status_code=401, detail={"code": "customer_auth_invalid", "reason": str(exc)}) from exc + actor_role = str(identity.get("actor_role") or "") + if actor_role not in COMMERCIAL_CUSTOMER_ROLES: + raise HTTPException(status_code=403, detail={"code": "customer_role_forbidden", "actor_role": actor_role}) + return identity + + +def _resolve_customer_account( + request: Request, + *, + identity: Dict[str, Any], + customer_account_id: Optional[str] = None, + account_id: Optional[str] = None, +) -> Dict[str, Any]: + service = request.app.state.customer_account_service + actor_role = str(identity.get("actor_role") or "") + identity_account_id = str(identity.get("account_id") or identity.get("actor_id") or "").strip() + resolved_customer_account_id = str(customer_account_id or "").strip() or None + resolved_account_id = str(account_id or "").strip() or None + if actor_role == "customer": + target_account_id = resolved_account_id or identity_account_id + if target_account_id != identity_account_id: + raise HTTPException( + status_code=403, + detail={ + "code": "customer_account_ownership_mismatch", + "provided_account_id": target_account_id, + "token_account_id": identity_account_id, + }, + ) + existing = service.repository.get_customer_account_by_account_id(target_account_id, default=None) + if existing is None: + service.ensure_customer_account( + account_id=target_account_id, + display_name=identity.get("display_name") or identity.get("actor_id"), + ) + detail = service.customer_account_detail(account_id=target_account_id) + if resolved_customer_account_id and detail["customer_account"]["customer_account_id"] != resolved_customer_account_id: + raise HTTPException(status_code=403, detail={"code": "customer_account_ownership_mismatch"}) + return detail + if resolved_customer_account_id: + return service.customer_account_detail(customer_account_id=resolved_customer_account_id) + if resolved_account_id: + return service.customer_account_detail(account_id=resolved_account_id) + if identity_account_id and service.repository.get_customer_account_by_account_id(identity_account_id, default=None): + return service.customer_account_detail(account_id=identity_account_id) + raise HTTPException(status_code=400, detail={"code": "customer_account_target_required"}) + + +def _customer_safe_response(request: Request, payload: Dict[str, Any]) -> Dict[str, Any]: + return request.app.state.commercial_audit_service.customer_safe_payload(payload) + + +@router.get("/account") +def customer_account( + request: Request, + customer_account_id: Optional[str] = None, + account_id: Optional[str] = None, +) -> Dict[str, Any]: + identity = _customer_identity(request) + detail = _resolve_customer_account( + request, + identity=identity, + customer_account_id=customer_account_id, + account_id=account_id, + ) + return _customer_safe_response(request, { + "identity": { + "actor_id": identity.get("actor_id"), + "account_id": identity.get("account_id"), + "actor_role": identity.get("actor_role"), + "display_name": identity.get("display_name"), + }, + **detail, + }) + + +@router.get("/invoice-preview") +def customer_invoice_preview( + request: Request, + customer_account_id: Optional[str] = None, + account_id: Optional[str] = None, + period_start: Optional[str] = None, +) -> Dict[str, Any]: + identity = _customer_identity(request) + detail = _resolve_customer_account( + request, + identity=identity, + customer_account_id=customer_account_id, + account_id=account_id, + ) + resolved_account_id = str((detail.get("customer_account") or {}).get("account_id") or "") + return _customer_safe_response(request, request.app.state.commercial_billing_service.invoice_preview( + account_id=resolved_account_id, + period_start=period_start, + )) + + +@router.get("/workspace") +def customer_workspace( + request: Request, + customer_account_id: Optional[str] = None, + account_id: Optional[str] = None, + period_start: Optional[str] = None, +) -> Dict[str, Any]: + identity = _customer_identity(request) + detail = _resolve_customer_account( + request, + identity=identity, + customer_account_id=customer_account_id, + account_id=account_id, + ) + resolved_account_id = str((detail.get("customer_account") or {}).get("account_id") or "") + return _customer_safe_response(request, request.app.state.customer_workspace_service.workspace( + account_id=resolved_account_id, + period_start=period_start, + )) + + +@router.get("/campaigns/{campaign_id}/report") +def customer_campaign_report( + campaign_id: str, + request: Request, + customer_account_id: Optional[str] = None, + account_id: Optional[str] = None, + period_start: Optional[str] = None, +) -> Dict[str, Any]: + identity = _customer_identity(request) + detail = _resolve_customer_account( + request, + identity=identity, + customer_account_id=customer_account_id, + account_id=account_id, + ) + resolved_account_id = str((detail.get("customer_account") or {}).get("account_id") or "") + return _customer_safe_response(request, request.app.state.customer_workspace_service.campaign_report( + account_id=resolved_account_id, + campaign_id=campaign_id, + period_start=period_start, + )) + + +@router.post("/campaigns") +def create_customer_campaign(payload: CampaignCreateRequest, request: Request) -> Dict[str, Any]: + identity = _customer_identity(request) + detail = _resolve_customer_account(request, identity=identity) + resolved_account_id = str((detail.get("customer_account") or {}).get("account_id") or "") + try: + return request.app.state.customer_campaign_service.create_or_update_campaign( + account_id=resolved_account_id, + payload=payload.model_dump(), + ) + except PermissionError as exc: + raise HTTPException(status_code=403, detail={"code": "campaign_account_ownership_mismatch", "reason": str(exc)}) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "campaign_create_invalid", "reason": str(exc)}) from exc + + +@router.post("/campaigns/{campaign_id}/submit") +def submit_customer_campaign(campaign_id: str, payload: CampaignSubmitRequest, request: Request) -> Dict[str, Any]: + identity = _customer_identity(request) + detail = _resolve_customer_account(request, identity=identity) + resolved_account_id = str((detail.get("customer_account") or {}).get("account_id") or "") + try: + return request.app.state.customer_campaign_service.submit_campaign( + account_id=resolved_account_id, + campaign_id=campaign_id, + ) + except PermissionError as exc: + raise HTTPException(status_code=403, detail={"code": "campaign_account_ownership_mismatch", "reason": str(exc)}) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "campaign_submit_invalid", "reason": str(exc)}) from exc + + +@router.post("/disputes") +def create_customer_dispute(payload: DisputeCreateRequest, request: Request) -> Dict[str, Any]: + identity = _customer_identity(request) + detail = _resolve_customer_account(request, identity=identity) + resolved_account_id = str((detail.get("customer_account") or {}).get("account_id") or "") + try: + dispute = request.app.state.commercial_support_service.create_dispute( + account_id=resolved_account_id, + requested_by=str(identity.get("actor_id") or resolved_account_id), + payload=payload.model_dump(), + ) + return _customer_safe_response(request, {"dispute": dispute}) + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "dispute_create_invalid", "reason": str(exc)}) from exc + + +@router.get("/disputes") +def customer_disputes(request: Request, status: Optional[str] = None, limit: int = 50) -> Dict[str, Any]: + identity = _customer_identity(request) + detail = _resolve_customer_account(request, identity=identity) + resolved_account_id = str((detail.get("customer_account") or {}).get("account_id") or "") + return _customer_safe_response(request, request.app.state.commercial_support_service.list_disputes(account_id=resolved_account_id, status=status, limit=limit)) + + +@router.post("/support") +def create_customer_support_case(payload: SupportCaseCreateRequest, request: Request) -> Dict[str, Any]: + identity = _customer_identity(request) + detail = _resolve_customer_account(request, identity=identity) + resolved_account_id = str((detail.get("customer_account") or {}).get("account_id") or "") + try: + support_case = request.app.state.commercial_support_service.create_support_case( + account_id=resolved_account_id, + requested_by=str(identity.get("actor_id") or resolved_account_id), + payload=payload.model_dump(), + ) + return _customer_safe_response(request, {"support_case": support_case}) + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "support_case_create_invalid", "reason": str(exc)}) from exc + + +@router.get("/support") +def customer_support_cases(request: Request, status: Optional[str] = None, limit: int = 50) -> Dict[str, Any]: + identity = _customer_identity(request) + detail = _resolve_customer_account(request, identity=identity) + resolved_account_id = str((detail.get("customer_account") or {}).get("account_id") or "") + return _customer_safe_response(request, request.app.state.commercial_support_service.list_support_cases(account_id=resolved_account_id, status=status, limit=limit)) + + +@router.get("/invoices") +def customer_invoices(request: Request, status: Optional[str] = None, limit: int = 50) -> Dict[str, Any]: + identity = _customer_identity(request) + detail = _resolve_customer_account(request, identity=identity) + resolved_account_id = str((detail.get("customer_account") or {}).get("account_id") or "") + payload = request.app.state.stripe_invoicing_service.list_invoices(account_id=resolved_account_id, limit=limit) + if status: + payload["invoices"] = [item for item in payload.get("invoices", []) if item.get("status") == status] + return _customer_safe_response(request, payload) + + +@router.get("/invoices/{invoice_id}") +def customer_invoice_detail(invoice_id: str, request: Request) -> Dict[str, Any]: + identity = _customer_identity(request) + detail = _resolve_customer_account(request, identity=identity) + resolved_account_id = str((detail.get("customer_account") or {}).get("account_id") or "") + payload = request.app.state.stripe_invoicing_service.get_invoice_detail(invoice_id=invoice_id) + if str((payload.get("invoice") or {}).get("account_id") or "") != resolved_account_id: + raise HTTPException(status_code=403, detail={"code": "customer_account_ownership_mismatch"}) + return _customer_safe_response(request, payload) + + +@router.get("/exports/{report_type}") +def customer_export( + report_type: str, + request: Request, + customer_account_id: Optional[str] = None, + account_id: Optional[str] = None, + period_start: Optional[str] = None, +) -> Dict[str, Any]: + identity = _customer_identity(request) + detail = _resolve_customer_account( + request, + identity=identity, + customer_account_id=customer_account_id, + account_id=account_id, + ) + resolved_account_id = str((detail.get("customer_account") or {}).get("account_id") or "") + try: + return _customer_safe_response(request, request.app.state.customer_workspace_service.export_payload( + account_id=resolved_account_id, + report_type=report_type, + period_start=period_start, + )) + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "customer_export_unknown", "reason": str(exc)}) from exc + + +@router.get("/audit-export") +def customer_audit_export( + request: Request, + customer_account_id: Optional[str] = None, + account_id: Optional[str] = None, + period_start: Optional[str] = None, + period_end: Optional[str] = None, +) -> Dict[str, Any]: + identity = _customer_identity(request) + detail = _resolve_customer_account( + request, + identity=identity, + customer_account_id=customer_account_id, + account_id=account_id, + ) + resolved_account_id = str((detail.get("customer_account") or {}).get("account_id") or "") + payload = request.app.state.commercial_audit_service.customer_audit_export( + account_id=resolved_account_id, + requested_by=str(identity.get("actor_id") or resolved_account_id), + period_start=period_start, + period_end=period_end, + ) + return payload + + +@router.post("/data-deletion-request") +def customer_data_deletion_request(payload: DataDeletionRequestPayload, request: Request) -> Dict[str, Any]: + identity = _customer_identity(request) + detail = _resolve_customer_account(request, identity=identity) + resolved_account_id = str((detail.get("customer_account") or {}).get("account_id") or "") + deletion_request = request.app.state.commercial_audit_service.create_data_deletion_request( + account_id=resolved_account_id, + requested_by=str(identity.get("actor_id") or resolved_account_id), + scope=payload.scope, + requested_payload=payload.requested_payload, + ) + return _customer_safe_response(request, {"deletion_request": deletion_request}) diff --git a/src/narrativeos/api/ops.py b/src/narrativeos/api/ops.py index 842c9a7..63a210b 100644 --- a/src/narrativeos/api/ops.py +++ b/src/narrativeos/api/ops.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging from typing import Any, Dict, Optional from fastapi import APIRouter, BackgroundTasks, HTTPException, Request @@ -51,6 +52,7 @@ build_learned_review_quality_world_detail, ) from ..services.provider_rollout import ProviderRolloutService +from ..services.ops_permissions import OpsPermissionPolicyService class PublishRequest(BaseModel): reviewer_id: Optional[str] = None @@ -199,6 +201,143 @@ class BillingRetryRequest(BaseModel): requested_by: Optional[str] = None +class AccountBillingReconcileRequest(BaseModel): + requested_by: Optional[str] = None + provider: Optional[str] = None + + +class BillableEventStatusRequest(BaseModel): + status: str + + +class CampaignDecisionRequest(BaseModel): + decision: str + note: Optional[str] = None + + +class PartnerStatusRequest(BaseModel): + status: str + note: Optional[str] = None + + +class ProductionSignoffInitializeRequest(BaseModel): + launch_label: Optional[str] = None + due_in_days: int = 2 + + +class ProductionSignoffAssignRequest(BaseModel): + owner_actor_id: Optional[str] = None + + +class ProductionSignoffDecisionRequest(BaseModel): + decision: str + note: Optional[str] = None + + +class ProductionSignoffEvidenceRequest(BaseModel): + evidence_type: str + summary: Optional[str] = None + source_ref: Dict[str, Any] = {} + payload: Dict[str, Any] = {} + customer_safe: bool = False + + +class ProductionSignoffOperatorEvidenceRequest(BaseModel): + evidence_key: str + summary: str + source_ref: Dict[str, Any] = {} + payload: Dict[str, Any] = {} + + +class ProductionSignoffOperatorCloseRequest(BaseModel): + decision: str + note: Optional[str] = None + + +class ProductionCutoverWindowRequest(BaseModel): + launch_wave: str + target_environment: str + starts_at: Optional[str] = None + ends_at: Optional[str] = None + rollback_owner_role: Optional[str] = None + status: str = "planned" + payload: Dict[str, Any] = {} + + +class ProductionAcceptanceGenerateRequest(BaseModel): + account_id: str + launch_wave: str = "wave_1" + signoff_id: Optional[str] = None + + +class LaunchWaveStatusUpdateRequest(BaseModel): + status: str + note: Optional[str] = None + + +class ProductionPreflightRunRequest(BaseModel): + signoff_id: Optional[str] = None + launch_wave: str = "wave_1" + target_environment: str = "production" + + +class CustomerSuccessSyncRequest(BaseModel): + account_id: Optional[str] = None + launch_wave: Optional[str] = None + + +class LaunchLedgerSyncRequest(BaseModel): + launch_wave: str = "wave_1" + + +class WaveActivationRequest(BaseModel): + note: Optional[str] = None + + +class GoLiveDayRunRequest(BaseModel): + launch_wave: str = "wave_1" + signoff_id: Optional[str] = None + account_id: Optional[str] = None + + +class LaunchWeekGuardSyncRequest(BaseModel): + launch_wave: str = "wave_1" + + +class DisputeDecisionRequest(BaseModel): + decision: str + note: Optional[str] = None + + +class ManualAdjustmentRequest(BaseModel): + account_id: str + dispute_id: Optional[str] = None + refund_request_id: Optional[str] = None + invoice_preview_id: Optional[str] = None + billable_event_id: Optional[str] = None + adjustment_type: str + amount_usd: float + target_billable_status: Optional[str] = None + adjustment_payload: Dict[str, Any] = {} + + +class SupportCaseStatusRequest(BaseModel): + status: str + note: Optional[str] = None + + +class InvoiceIssueRequest(BaseModel): + requested_by: Optional[str] = None + + +class InvoiceRetryRequest(BaseModel): + requested_by: Optional[str] = None + + +class LifecycleAutomationSyncRequest(BaseModel): + account_id: str + + class InvestigationRequest(BaseModel): limit: int = 50 @@ -210,6 +349,21 @@ class AlertStatusRequest(BaseModel): note: Optional[str] = None +class OpsReviewItemAssignRequest(BaseModel): + owner_id: str + reviewer_id: Optional[str] = None + + +class OpsReviewItemStatusRequest(BaseModel): + status: str + reviewer_id: Optional[str] = None + + +class OpsReviewItemDecisionRequest(BaseModel): + decision: str + reviewer_id: Optional[str] = None + + class GovernanceCaseRequest(BaseModel): case_type: str target_type: str @@ -273,6 +427,49 @@ class GovernanceRestrictionReleaseRequest(BaseModel): release_reason: Optional[str] = None +class GovernanceRestrictionUpdateRequest(BaseModel): + reviewer_id: Optional[str] = None + restriction_type: Optional[str] = None + restriction_reason: Optional[str] = None + expires_at: Optional[str] = None + + +class GovernanceCaseRestrictionRequest(BaseModel): + reviewer_id: Optional[str] = None + restriction_type: str + restriction_reason: Optional[str] = None + expires_at: Optional[str] = None + + +class GovernanceBulkActionRequest(BaseModel): + case_ids: list[str] + action: str + owner_id: Optional[str] = None + owner_assignments: Dict[str, str] = {} + due_at: Optional[str] = None + note: Optional[str] = None + status: Optional[str] = None + resolution_notes: Optional[str] = None + disposition: Optional[str] = None + policy_labels: list[str] = [] + restriction_type: Optional[str] = None + restriction_reason: Optional[str] = None + expires_at: Optional[str] = None + reviewer_id: Optional[str] = None + + +class GovernanceCapacityOverrideRequest(BaseModel): + reviewer_id: Optional[str] = None + capacity_units_per_day: Optional[float] = None + critical_case_limit: Optional[int] = None + active_restriction_limit: Optional[int] = None + sla_hours: Optional[int] = None + role_multiplier: Optional[float] = None + enabled: Optional[bool] = None + clear_override: bool = False + note: Optional[str] = None + + class GovernanceSupportEscalationRequest(BaseModel): issue_id: str reviewer_id: Optional[str] = None @@ -404,28 +601,63 @@ class AsyncJobHandoffSlaRequest(BaseModel): router = APIRouter(prefix="/v1/ops", tags=["ops"]) +logger = logging.getLogger(__name__) ACTOR_ID_HEADER = "X-NarrativeOS-Actor-Id" ACTOR_ROLE_HEADER = "X-NarrativeOS-Actor-Role" ACCOUNT_ID_HEADER = "X-NarrativeOS-Account-Id" +ADMIN_VIEW_BRIDGE_HEADER = "X-NarrativeOS-Admin-Bridge" def _ops_request_identity(request: Request) -> Dict[str, Optional[str]]: + bridge_token = request.headers.get(ADMIN_VIEW_BRIDGE_HEADER) or "" + if bridge_token.strip(): + try: + resolved = request.app.state.auth_service.resolve_admin_view_bridge_token(raw_token=bridge_token.strip()) + except (PermissionError, KeyError) as exc: + raise HTTPException(status_code=401, detail={"code": "admin_view_bridge_invalid", "reason": str(exc)}) from exc + return { + "actor_id": resolved.get("actor_id"), + "actor_role": resolved.get("actor_role"), + "account_id": resolved.get("account_id") or resolved.get("context", {}).get("account_id"), + } authorization = request.headers.get("Authorization") or "" if authorization.lower().startswith("bearer "): - raw_token = authorization.split(" ", 1)[1].strip() - if raw_token: - try: - resolved = request.app.state.auth_service.resolve_bearer_token(raw_token) - except (PermissionError, KeyError) as exc: - raise HTTPException(status_code=401, detail={"code": "auth_token_invalid", "reason": str(exc)}) from exc - return { - "actor_id": resolved.get("actor_id"), - "actor_role": resolved.get("actor_role"), - "account_id": resolved.get("account_id"), - } + raw_token = request.app.state.auth_service.extract_request_token( + authorization=authorization, + cookies=None, + ) + try: + resolved = request.app.state.auth_service.resolve_bearer_token(raw_token or "") + except (PermissionError, KeyError) as exc: + raise HTTPException(status_code=401, detail={"code": "auth_token_invalid", "reason": str(exc)}) from exc + return { + "actor_id": resolved.get("actor_id"), + "actor_role": resolved.get("actor_role"), + "account_id": resolved.get("account_id"), + } actor_id = request.headers.get(ACTOR_ID_HEADER) actor_role = request.headers.get(ACTOR_ROLE_HEADER) account_id = request.headers.get(ACCOUNT_ID_HEADER) + if actor_id or actor_role or account_id: + return { + "actor_id": actor_id.strip() if actor_id else None, + "actor_role": actor_role.strip() if actor_role else None, + "account_id": account_id.strip() if account_id else None, + } + raw_token = request.app.state.auth_service.extract_request_token( + authorization=None, + cookies=request.cookies, + ) + if raw_token: + try: + resolved = request.app.state.auth_service.resolve_bearer_token(raw_token) + except (PermissionError, KeyError) as exc: + raise HTTPException(status_code=401, detail={"code": "auth_token_invalid", "reason": str(exc)}) from exc + return { + "actor_id": resolved.get("actor_id"), + "actor_role": resolved.get("actor_role"), + "account_id": resolved.get("account_id"), + } return { "actor_id": actor_id.strip() if actor_id else None, "actor_role": actor_role.strip() if actor_role else None, @@ -459,10 +691,18 @@ def _ops_actor(request: Request, fallback_reviewer_id: Optional[str] = None) -> def _require_ops_reviewer(request: Request, fallback_reviewer_id: Optional[str] = None) -> Dict[str, Optional[str]]: actor = _ops_actor(request, fallback_reviewer_id) - if not actor["actor_id"]: - raise HTTPException(status_code=403, detail={"code": "ops_actor_missing", "reason": "reviewer_identity_required"}) - if actor["actor_role"] not in {"reviewer", "ops"}: - raise HTTPException(status_code=403, detail={"code": "ops_actor_forbidden", "reason": "reviewer_or_ops_required"}) + try: + request.app.state.ops_permission_policy.authorize_roles( + actor_id=actor["actor_id"], + actor_role=actor["actor_role"], + allowed_roles={"reviewer", "ops", "admin"}, + missing_reason="reviewer_identity_required", + forbidden_reason="reviewer_or_ops_required", + ) + except PermissionError as exc: + reason = str(exc) + code = "ops_actor_missing" if "identity_required" in reason else "ops_actor_forbidden" + raise HTTPException(status_code=403, detail={"code": code, "reason": reason}) from exc return actor @@ -475,10 +715,18 @@ def _require_ops_roles( forbidden_reason: str = "ops_role_forbidden", ) -> Dict[str, Optional[str]]: actor = _ops_actor(request, fallback_actor_id) - if not actor["actor_id"]: - raise HTTPException(status_code=403, detail={"code": "ops_actor_missing", "reason": missing_reason}) - if str(actor["actor_role"] or "") not in allowed_roles: - raise HTTPException(status_code=403, detail={"code": "ops_actor_forbidden", "reason": forbidden_reason}) + try: + request.app.state.ops_permission_policy.authorize_roles( + actor_id=actor["actor_id"], + actor_role=actor["actor_role"], + allowed_roles=allowed_roles, + missing_reason=missing_reason, + forbidden_reason=forbidden_reason, + ) + except PermissionError as exc: + reason = str(exc) + code = "ops_actor_missing" if "identity_required" in reason else "ops_actor_forbidden" + raise HTTPException(status_code=403, detail={"code": code, "reason": reason}) from exc return actor @@ -500,24 +748,205 @@ def _require_restore_admin(request: Request) -> Dict[str, Optional[str]]: ) +def ensure_ops_read_access(request: Request) -> Dict[str, Optional[str]]: + actor = _ops_actor(request) + try: + request.app.state.ops_permission_policy.authorize_read( + actor_id=actor["actor_id"], + actor_role=actor["actor_role"], + ) + except PermissionError as exc: + reason = str(exc) + code = "ops_actor_missing" if "identity_required" in reason else "ops_actor_forbidden" + raise HTTPException(status_code=403, detail={"code": code, "reason": reason}) from exc + return actor + + +def ensure_ops_write_access(request: Request) -> Dict[str, Optional[str]]: + actor = _ops_actor(request) + try: + request.app.state.ops_permission_policy.authorize_write( + actor_id=actor["actor_id"], + actor_role=actor["actor_role"], + method=request.method, + path=request.url.path.rstrip("/") or request.url.path, + ) + except PermissionError as exc: + reason = str(exc) + code = "ops_actor_missing" if "identity_required" in reason else "ops_actor_forbidden" + raise HTTPException(status_code=403, detail={"code": code, "reason": reason}) from exc + return actor + + @router.get("/review-queue") def review_queue(request: Request) -> Dict[str, Any]: return {"reviews": request.app.state.review_service.queue()} -@router.get("/worlds/{world_id}/status") -def world_status(world_id: str, request: Request) -> Dict[str, Any]: - payload = request.app.state.review_service.world_status(world_id) - payload["learned_shadow_summary"] = request.app.state.learned_shadow_service.summarize( - payload.get("latest_simulation", {}).get("learned_evaluation_summary", {}) - ) - reranker_bundle = request.app.state.training_signal_service.export_bundle( +@router.get("/review-hub") +def review_hub( + request: Request, + queue: Optional[str] = None, + status: Optional[str] = None, + owner_id: Optional[str] = None, + severity: Optional[str] = None, + account_id: Optional[str] = None, + world_id: Optional[str] = None, + world_version_id: Optional[str] = None, + limit: int = 100, +) -> Dict[str, Any]: + return request.app.state.ops_review_hub_service.review_hub( + queue=queue, + status=status, + owner_id=owner_id, + severity=severity, + account_id=account_id, world_id=world_id, - dataset_view="reranker", - ) - payload["learned_reranker_shadow_summary"] = request.app.state.learned_reranker_shadow_service.summarize( - reranker_bundle + world_version_id=world_version_id, + limit=limit, ) + + +@router.get("/review-items/{review_item_id}") +def review_item_detail(review_item_id: str, request: Request) -> Dict[str, Any]: + try: + return request.app.state.ops_review_hub_service.review_item_detail(review_item_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + + +@router.get("/review-items/{review_item_id}/work") +def review_item_work_detail(review_item_id: str, request: Request) -> Dict[str, Any]: + try: + return request.app.state.ops_review_hub_service.review_item_work_detail(review_item_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + + +@router.post("/review-items/{review_item_id}/assign") +def assign_review_item(review_item_id: str, payload: OpsReviewItemAssignRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, payload.reviewer_id) + try: + return request.app.state.ops_review_hub_service.assign_review_item( + review_item_id=review_item_id, + owner_id=payload.owner_id, + reviewer_id=str(actor["actor_id"]), + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.post("/review-items/{review_item_id}/status") +def update_review_item_status(review_item_id: str, payload: OpsReviewItemStatusRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, payload.reviewer_id) + try: + return request.app.state.ops_review_hub_service.update_review_item_status( + review_item_id=review_item_id, + status=payload.status, + reviewer_id=str(actor["actor_id"]), + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.post("/review-items/{review_item_id}/decision") +def decide_review_item(review_item_id: str, payload: OpsReviewItemDecisionRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, payload.reviewer_id) + try: + return request.app.state.ops_review_hub_service.decide_review_item( + review_item_id=review_item_id, + decision=payload.decision, + reviewer_id=str(actor["actor_id"]), + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.get("/worlds/{world_id}/status") +def world_status(world_id: str, request: Request) -> Dict[str, Any]: + versions = request.app.state.repository.list_world_versions(world_id=world_id) + published_version = next((item["world_version_id"] for item in versions if item.get("status") == "published"), None) + rollback_targets = [item for item in versions if item.get("world_version_id") != published_version] + payload: Dict[str, Any] = { + "world_id": world_id, + "versions": versions, + "published_version": published_version, + "evaluation_summary": {}, + "latest_simulation": {}, + "publish_checklist": [], + "publish_checklist_summary": { + "total": 0, + "ok_count": 0, + "blocked_count": 0, + "publish_ready": False, + "blocker_keys": [], + "owners": [], + "next_actions": [], + "review_status_counts": {}, + }, + "recent_reviews": [], + "recent_reviews_drilldown": [], + "rollback_targets": rollback_targets, + "recent_entitlement_events": [], + "risk_summary": { + "publish_ready": False, + "publish_gate_errors": [], + "latest_rollback_reason": None, + "latest_rollback_target": None, + "entitlement_alerts": [], + }, + "release_evidence_bundle": {}, + "author_longform_capability": {}, + "author_claim_alignment": {}, + "longform_1000_readiness": {}, + "longform_1000_interactive_signoff": {}, + "longform_1000_human_review_closeout": {}, + "longform_1000_feasibility": {}, + "character_fidelity_remediation_framework": {}, + "quality_projection_summary": {}, + "status_warnings": [], + } + try: + payload.update(request.app.state.review_service.world_status(world_id)) + except Exception as exc: # pragma: no cover - production fallback + logger.exception("ops world status base payload failed", extra={"world_id": world_id}) + payload["status_warnings"].append({"stage": "review_world_status", "reason": str(exc)}) + try: + payload["learned_shadow_summary"] = request.app.state.learned_shadow_service.summarize( + payload.get("latest_simulation", {}).get("learned_evaluation_summary", {}) + ) + except Exception as exc: # pragma: no cover - production fallback + logger.exception("ops world status learned shadow summary failed", extra={"world_id": world_id}) + payload["learned_shadow_summary"] = { + "available": False, + "status": "unavailable", + "warnings": [str(exc)], + "recommended_next_action": "inspect_world_status_warning", + } + payload["status_warnings"].append({"stage": "learned_shadow_summary", "reason": str(exc)}) + try: + reranker_bundle = request.app.state.training_signal_service.export_bundle( + world_id=world_id, + dataset_view="reranker", + ) + payload["learned_reranker_shadow_summary"] = request.app.state.learned_reranker_shadow_service.summarize( + reranker_bundle + ) + except Exception as exc: # pragma: no cover - production fallback + logger.exception("ops world status reranker summary failed", extra={"world_id": world_id}) + payload["learned_reranker_shadow_summary"] = { + "available": False, + "status": "unavailable", + "warnings": [str(exc)], + "recommended_next_action": "inspect_world_status_warning", + } + payload["status_warnings"].append({"stage": "learned_reranker_shadow_summary", "reason": str(exc)}) return payload @@ -529,6 +958,16 @@ def world_release_workspace(world_id: str, request: Request, limit: int = 12) -> raise HTTPException(status_code=404, detail=str(exc)) +@router.get("/worlds/{world_id}/release-evidence-bundle") +def world_release_evidence_bundle(world_id: str, request: Request) -> Dict[str, Any]: + payload = request.app.state.review_service.world_status(world_id) + return { + "world_id": world_id, + "release_evidence_bundle": payload.get("release_evidence_bundle", {}), + "published_version": payload.get("published_version"), + } + + @router.post("/world-versions/{world_version_id}/publish") def publish_world_version(world_version_id: str, payload: PublishRequest, request: Request) -> Dict[str, Any]: try: @@ -602,32 +1041,111 @@ def runtime_receipts( } -@router.get("/runtime-incident-snapshot") -def runtime_incident_snapshot( +@router.get("/quality/summary") +def quality_summary( request: Request, account_id: Optional[str] = None, - limit: int = 20, + world_version_id: Optional[str] = None, + session_id: Optional[str] = None, + source_surface: Optional[str] = None, + status: Optional[str] = None, + limit: int = 50, ) -> Dict[str, Any]: - return request.app.state.observability_service.runtime_incident_snapshot( + return request.app.state.ops_quality_projection_service.quality_summary( account_id=account_id, + world_version_id=world_version_id, + session_id=session_id, + source_surface=source_surface, + status=status, limit=limit, ) -@router.get("/provider-routing") -def provider_routing_policy( +@router.get("/quality/events") +def quality_events( request: Request, + account_id: Optional[str] = None, + world_version_id: Optional[str] = None, + session_id: Optional[str] = None, + source_surface: Optional[str] = None, + status: Optional[str] = None, + limit: int = 50, ) -> Dict[str, Any]: - return request.app.state.provider_routing_service.policy_summary() + summary = request.app.state.ops_quality_projection_service.quality_summary( + account_id=account_id, + world_version_id=world_version_id, + session_id=session_id, + source_surface=source_surface, + status=status, + limit=limit, + ) + return { + "generated_at": summary.get("generated_at"), + "filters": summary.get("filters", {}), + "summary": summary.get("summary", {}), + "events": summary.get("events", []), + } -@router.get("/provider-rollout") -def provider_rollout_summary( - request: Request, -) -> Dict[str, Any]: - return request.app.state.provider_rollout_service.summary( - candidate_backend_present=request.app.state.candidate_backend is not None, - renderer_backend_present=request.app.state.renderer_backend is not None, +@router.get("/quality/traces/{trace_id}") +def quality_trace_detail(trace_id: str, request: Request) -> Dict[str, Any]: + try: + payload = request.app.state.ops_quality_projection_service.quality_trace_detail(trace_id) + account_id = str((payload.get("linked_context") or {}).get("account_id") or "").strip() + if account_id: + request.app.state.commercial_billing_service.sync_account_billing(account_id=account_id) + payload["billing_projection"] = request.app.state.commercial_billing_service.trace_billing_projection(trace_id=trace_id) + return payload + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + + +@router.get("/runtime-incident-snapshot") +def runtime_incident_snapshot( + request: Request, + account_id: Optional[str] = None, + limit: int = 20, +) -> Dict[str, Any]: + return request.app.state.observability_service.runtime_incident_snapshot( + account_id=account_id, + limit=limit, + ) + + +@router.get("/story-bootstrap-world-summary") +def story_bootstrap_world_summary( + request: Request, + limit: int = 50, +) -> Dict[str, Any]: + return request.app.state.observability_service.story_bootstrap_world_summary(limit=limit) + + +@router.get("/story-bootstrap-world-summary/worlds/{world_id}") +def story_bootstrap_world_detail( + world_id: str, + request: Request, + limit: int = 20, +) -> Dict[str, Any]: + try: + return request.app.state.observability_service.story_bootstrap_world_detail(world_id, limit=limit) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + + +@router.get("/provider-routing") +def provider_routing_policy( + request: Request, +) -> Dict[str, Any]: + return request.app.state.provider_routing_service.policy_summary() + + +@router.get("/provider-rollout") +def provider_rollout_summary( + request: Request, +) -> Dict[str, Any]: + return request.app.state.provider_rollout_service.summary( + candidate_backend_present=request.app.state.candidate_backend is not None, + renderer_backend_present=request.app.state.renderer_backend is not None, ) @@ -1167,96 +1685,778 @@ def revoke_runtime_restore(request_id: str, payload: RuntimeRestoreRevokeRequest except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) except KeyError as exc: - raise HTTPException(status_code=404, detail=str(exc)) - except PermissionError as exc: - raise HTTPException(status_code=403, detail=str(exc)) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) - return {"restore_request": restore_request} + raise HTTPException(status_code=404, detail=str(exc)) + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return {"restore_request": restore_request} + + +@router.post("/recovery-drill") +def recovery_drill(payload: RuntimeRecoveryDrillRequest, request: Request) -> Dict[str, Any]: + result = request.app.state.runtime_ops_service.run_recovery_drill( + backup_path=payload.backup_path, + output_dir=payload.output_dir, + ) + request.app.state.analytics_service.track( + "runtime_recovery_drill_ran", + payload_json=result, + ) + return {"recovery_drill": result} + + +@router.post("/jobs/runtime-restores") +def enqueue_runtime_restore_job( + payload: AsyncRuntimeRestoreJobRequest, + background_tasks: BackgroundTasks, + request: Request, +) -> Dict[str, Any]: + try: + actor = _require_restore_admin(request) + job = request.app.state.async_job_service.enqueue_job( + job_type="runtime_restore", + payload={ + "request_id": payload.request_id, + "requested_by": str(actor["actor_id"]), + }, + requested_by=str(actor["actor_id"]), + account_id=payload.account_id, + schedule=background_tasks.add_task, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return {"job": job} + + +@router.post("/runtime-backups") +def runtime_backup(payload: RuntimeBackupRequest, request: Request) -> Dict[str, Any]: + result = request.app.state.runtime_ops_service.create_backup( + label=payload.label, + output_dir=payload.output_dir, + dry_run=payload.dry_run, + ) + request.app.state.analytics_service.track( + "runtime_backup_created", + payload_json=result, + ) + return {"backup": result} + + +@router.post("/runtime-restore") +def runtime_restore(payload: RuntimeRestoreRequest, request: Request) -> Dict[str, Any]: + result = request.app.state.runtime_ops_service.restore_backup( + backup_path=payload.backup_path, + dry_run=payload.dry_run, + ) + request.app.state.analytics_service.track( + "runtime_restore_applied" if not payload.dry_run else "runtime_restore_planned", + payload_json=result, + ) + return {"restore": result} + + +@router.get("/subscriptions") +def list_subscriptions(account_id: Optional[str] = None, status: Optional[str] = None, request: Request = None) -> Dict[str, Any]: + return request.app.state.billing_service.list_subscriptions(account_id=account_id, status=status) + + +@router.get("/entitlements") +def list_entitlements(account_id: Optional[str] = None, reader_id: Optional[str] = None, world_id: Optional[str] = None, request: Request = None) -> Dict[str, Any]: + resolved_account_id = request.app.state.billing_service.resolve_account_id(account_id=account_id, reader_id=reader_id) + return request.app.state.billing_service.entitlement_audit(account_id=resolved_account_id, world_id=world_id) + + +@router.get("/accounts/{account_id}") +def account_detail(account_id: str, request: Request, limit: int = 10) -> Dict[str, Any]: + return request.app.state.billing_service.account_detail(account_id=account_id, limit=limit) + + +@router.get("/accounts/{account_id}/workspace") +def account_workspace(account_id: str, request: Request, limit: int = 12) -> Dict[str, Any]: + return request.app.state.ops_account_workspace_service.account_workspace(account_id=account_id, limit=limit) + + +@router.get("/customers") +def list_customers( + request: Request, + status: Optional[str] = None, + limit: int = 50, +) -> Dict[str, Any]: + return request.app.state.customer_account_service.list_customer_accounts(status=status, limit=limit) + + +@router.get("/customers/{customer_account_id}") +def customer_detail(customer_account_id: str, request: Request) -> Dict[str, Any]: + return request.app.state.customer_account_service.customer_account_detail(customer_account_id=customer_account_id) + + +@router.get("/billing/usage-ledgers") +def list_usage_ledgers( + request: Request, + account_id: Optional[str] = None, + limit: int = 50, +) -> Dict[str, Any]: + if account_id: + request.app.state.commercial_billing_service.sync_account_billing(account_id=account_id) + return request.app.state.commercial_billing_service.list_usage_ledgers(account_id=account_id, limit=limit) + + +@router.get("/billing/invoice-previews") +def list_invoice_previews( + request: Request, + account_id: Optional[str] = None, + limit: int = 50, +) -> Dict[str, Any]: + if account_id: + request.app.state.commercial_billing_service.sync_account_billing(account_id=account_id) + return request.app.state.commercial_billing_service.list_invoice_previews(account_id=account_id, limit=limit) + + +@router.post("/billing/billable-events/{billable_event_id}/status") +def update_billable_event_status( + billable_event_id: str, + payload: BillableEventStatusRequest, + request: Request, +) -> Dict[str, Any]: + try: + event = request.app.state.commercial_billing_service.update_billable_event_status( + billable_event_id=billable_event_id, + status=payload.status, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "billable_event_status_invalid", "reason": str(exc)}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "billable_event_missing", "reason": str(exc)}) from exc + return {"billable_event": event} + + +@router.post("/campaigns/{campaign_id}/decision") +def decide_campaign(campaign_id: str, payload: CampaignDecisionRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + try: + return request.app.state.customer_campaign_service.decide_campaign( + campaign_id=campaign_id, + reviewer_id=str(actor["actor_id"]), + decision=payload.decision, + note=payload.note, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "campaign_decision_invalid", "reason": str(exc)}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "campaign_missing", "reason": str(exc)}) from exc + + +@router.get("/partners") +def list_partners( + request: Request, + lifecycle_status: Optional[str] = None, + limit: int = 50, +) -> Dict[str, Any]: + return request.app.state.partner_readiness_service.list_partners(lifecycle_status=lifecycle_status, limit=limit) + + +@router.get("/partners/{partner_id}") +def partner_detail(partner_id: str, request: Request) -> Dict[str, Any]: + try: + return request.app.state.partner_readiness_service.partner_detail(partner_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "partner_missing", "reason": str(exc)}) from exc + + +@router.post("/partners/{partner_id}/status") +def update_partner_status(partner_id: str, payload: PartnerStatusRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + try: + return request.app.state.partner_readiness_service.change_status( + partner_id=partner_id, + status=payload.status, + note=payload.note or str(actor["actor_id"]), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "partner_status_invalid", "reason": str(exc)}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "partner_missing", "reason": str(exc)}) from exc + + +@router.get("/disputes") +def list_disputes(request: Request, account_id: Optional[str] = None, status: Optional[str] = None, limit: int = 50) -> Dict[str, Any]: + return request.app.state.commercial_support_service.list_disputes(account_id=account_id, status=status, limit=limit) + + +@router.post("/disputes/{dispute_id}/decision") +def decide_dispute(dispute_id: str, payload: DisputeDecisionRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + try: + return request.app.state.commercial_support_service.decide_dispute( + dispute_id=dispute_id, + reviewer_id=str(actor["actor_id"]), + decision=payload.decision, + note=payload.note, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "dispute_decision_invalid", "reason": str(exc)}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "dispute_missing", "reason": str(exc)}) from exc + + +@router.post("/manual-adjustments") +def create_manual_adjustment(payload: ManualAdjustmentRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + try: + adjustment = request.app.state.commercial_support_service.create_manual_adjustment( + account_id=payload.account_id, + reviewer_id=str(actor["actor_id"]), + payload=payload.model_dump(), + ) + return {"manual_adjustment": adjustment} + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "manual_adjustment_invalid", "reason": str(exc)}) from exc + + +@router.get("/support-cases") +def list_support_cases(request: Request, account_id: Optional[str] = None, status: Optional[str] = None, limit: int = 50) -> Dict[str, Any]: + return request.app.state.commercial_support_service.list_support_cases(account_id=account_id, status=status, limit=limit) + + +@router.post("/support-cases/{support_case_id}/status") +def update_support_case_status(support_case_id: str, payload: SupportCaseStatusRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + try: + support_case = request.app.state.commercial_support_service.update_support_case_status( + support_case_id=support_case_id, + reviewer_id=str(actor["actor_id"]), + status=payload.status, + note=payload.note, + ) + return {"support_case": support_case} + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "support_case_status_invalid", "reason": str(exc)}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "support_case_missing", "reason": str(exc)}) from exc + + +@router.post("/invoices/{invoice_preview_id}/issue") +def issue_invoice(invoice_preview_id: str, payload: InvoiceIssueRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + try: + return request.app.state.stripe_invoicing_service.issue_invoice( + invoice_preview_id=invoice_preview_id, + requested_by=payload.requested_by or str(actor["actor_id"]), + ) + except (ValueError, RuntimeError) as exc: + raise HTTPException(status_code=400, detail={"code": "invoice_issue_invalid", "reason": str(exc)}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "invoice_preview_missing", "reason": str(exc)}) from exc + + +@router.get("/invoices") +def list_issued_invoices(request: Request, account_id: Optional[str] = None, limit: int = 50) -> Dict[str, Any]: + return request.app.state.stripe_invoicing_service.list_invoices(account_id=account_id, limit=limit) + + +@router.post("/invoices/{invoice_id}/retry-payment") +def retry_invoice_payment(invoice_id: str, payload: InvoiceRetryRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + try: + return request.app.state.stripe_invoicing_service.retry_invoice_payment( + invoice_id=invoice_id, + requested_by=payload.requested_by or str(actor["actor_id"]), + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "invoice_missing", "reason": str(exc)}) from exc + + +@router.post("/provider-webhooks/{provider_webhook_event_id}/replay") +def replay_provider_webhook(provider_webhook_event_id: str, request: Request) -> Dict[str, Any]: + _require_ops_reviewer(request, None) + try: + return request.app.state.stripe_invoicing_service.replay_webhook(provider_webhook_event_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "provider_webhook_missing", "reason": str(exc)}) from exc + + +@router.get("/audit") +def list_ops_audit( + request: Request, + account_id: Optional[str] = None, + customer_account_id: Optional[str] = None, + action_type: Optional[str] = None, + limit: int = 100, +) -> Dict[str, Any]: + return request.app.state.commercial_audit_service.audit_log_listing( + account_id=account_id, + customer_account_id=customer_account_id, + action_type=action_type, + limit=limit, + ) + + +@router.get("/commercialization-summary") +def commercialization_summary(request: Request, limit: int = 50) -> Dict[str, Any]: + return request.app.state.ops_commercialization_dashboard_service.summary(limit=limit) + + +@router.get("/production-signoff") +def list_production_signoff(request: Request, limit: int = 25) -> Dict[str, Any]: + return request.app.state.production_signoff_service.list_signoffs(limit=limit) + + +@router.post("/production-signoff/initialize") +def initialize_production_signoff(payload: ProductionSignoffInitializeRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + try: + return request.app.state.production_signoff_service.initialize_signoff_run( + actor_id=str(actor["actor_id"]), + actor_role=str(actor["actor_role"]), + launch_label=payload.launch_label, + due_in_days=payload.due_in_days, + ) + except FileNotFoundError as exc: + raise HTTPException(status_code=400, detail={"code": "production_signoff_seed_artifact_missing", "reason": str(exc)}) from exc + + +@router.get("/production-signoff/{signoff_id}") +def production_signoff_detail(signoff_id: str, request: Request) -> Dict[str, Any]: + try: + return request.app.state.production_signoff_service.signoff_detail(signoff_id=signoff_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "production_signoff_missing", "reason": str(exc)}) from exc + + +@router.post("/production-signoff/items/{signoff_item_id}/assign") +def assign_production_signoff_item(signoff_item_id: str, payload: ProductionSignoffAssignRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + try: + return request.app.state.production_signoff_service.assign_signoff_item_owner( + actor_id=str(actor["actor_id"]), + actor_role=str(actor["actor_role"]), + signoff_item_id=signoff_item_id, + owner_actor_id=payload.owner_actor_id, + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "production_signoff_item_missing", "reason": str(exc)}) from exc + + +@router.post("/production-signoff/items/{signoff_item_id}/decision") +def decide_production_signoff_item(signoff_item_id: str, payload: ProductionSignoffDecisionRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + try: + return request.app.state.production_signoff_service.decide_signoff_item( + actor_id=str(actor["actor_id"]), + actor_role=str(actor["actor_role"]), + signoff_item_id=signoff_item_id, + decision=payload.decision, + note=payload.note, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "production_signoff_decision_invalid", "reason": str(exc)}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "production_signoff_item_missing", "reason": str(exc)}) from exc + + +@router.post("/production-signoff/items/{signoff_item_id}/evidence") +def append_production_signoff_evidence(signoff_item_id: str, payload: ProductionSignoffEvidenceRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + try: + return request.app.state.production_signoff_service.append_signoff_evidence( + actor_id=str(actor["actor_id"]), + actor_role=str(actor["actor_role"]), + signoff_item_id=signoff_item_id, + evidence_type=payload.evidence_type, + summary=payload.summary, + source_ref=payload.source_ref, + payload=payload.payload, + customer_safe=payload.customer_safe, + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "production_signoff_item_missing", "reason": str(exc)}) from exc + + +@router.post("/production-signoff/items/{signoff_item_id}/operator-evidence") +def append_production_signoff_operator_evidence(signoff_item_id: str, payload: ProductionSignoffOperatorEvidenceRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + try: + return request.app.state.human_signoff_closure_service.append_operator_evidence( + actor_id=str(actor["actor_id"]), + actor_role=str(actor["actor_role"]), + signoff_item_id=signoff_item_id, + evidence_key=payload.evidence_key, + summary=payload.summary, + source_ref=payload.source_ref, + payload=payload.payload, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "production_operator_evidence_invalid", "reason": str(exc)}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "production_signoff_item_missing", "reason": str(exc)}) from exc + + +@router.post("/production-signoff/items/{signoff_item_id}/operator-close") +def close_production_signoff_operator_item(signoff_item_id: str, payload: ProductionSignoffOperatorCloseRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + try: + return request.app.state.human_signoff_closure_service.close_operator_item( + actor_id=str(actor["actor_id"]), + actor_role=str(actor["actor_role"]), + signoff_item_id=signoff_item_id, + decision=payload.decision, + note=payload.note, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail={"code": "production_operator_close_invalid", "reason": str(exc)}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "production_signoff_item_missing", "reason": str(exc)}) from exc + + +@router.post("/production-signoff/{signoff_id}/cutover-window") +def mark_production_cutover_window(signoff_id: str, payload: ProductionCutoverWindowRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + try: + return request.app.state.production_signoff_service.mark_cutover_window( + actor_id=str(actor["actor_id"]), + actor_role=str(actor["actor_role"]), + signoff_id=signoff_id, + launch_wave=payload.launch_wave, + target_environment=payload.target_environment, + starts_at=payload.starts_at, + ends_at=payload.ends_at, + rollback_owner_role=payload.rollback_owner_role, + status=payload.status, + payload=payload.payload, + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "production_signoff_missing", "reason": str(exc)}) from exc + + +@router.get("/production-signoff/{signoff_id}/export") +def export_production_signoff(signoff_id: str, request: Request) -> Dict[str, Any]: + try: + return request.app.state.production_signoff_service.export_signoff_record(signoff_id=signoff_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "production_signoff_missing", "reason": str(exc)}) from exc + + +@router.get("/production-signoff-board") +def production_signoff_board(request: Request, signoff_id: Optional[str] = None) -> Dict[str, Any]: + try: + return request.app.state.production_signoff_board_service.board(signoff_id=signoff_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "production_signoff_missing", "reason": str(exc)}) from exc + + +@router.get("/human-signoff-closure") +def human_signoff_closure(request: Request, signoff_id: Optional[str] = None) -> Dict[str, Any]: + pack = request.app.state.human_signoff_closure_service.build_pack(signoff_id=signoff_id) + return { + "closure": request.app.state.human_signoff_closure_service.closure(signoff_id=signoff_id), + "artifact_refs": pack, + } + + +@router.get("/human-signoff-closure/{owner_role}") +def human_signoff_closure_owner(owner_role: str, request: Request, signoff_id: Optional[str] = None) -> Dict[str, Any]: + pack = request.app.state.human_signoff_closure_service.build_pack(signoff_id=signoff_id) + return { + "closure": request.app.state.human_signoff_closure_service.closure(signoff_id=signoff_id, owner_role=owner_role), + "artifact_refs": pack, + } + + +@router.post("/production-preflight/runs") +def run_production_preflight(payload: ProductionPreflightRunRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + return request.app.state.production_preflight_service.run_preflight( + actor_id=str(actor["actor_id"]), + actor_role=str(actor["actor_role"]), + signoff_id=payload.signoff_id, + launch_wave=payload.launch_wave, + target_environment=payload.target_environment, + ) + + +@router.get("/production-preflight") +def list_production_preflight( + request: Request, + signoff_id: Optional[str] = None, + launch_wave: Optional[str] = None, + limit: int = 25, +) -> Dict[str, Any]: + return request.app.state.production_preflight_service.list_runs( + signoff_id=signoff_id, + launch_wave=launch_wave, + limit=limit, + ) + + +@router.get("/production-preflight/{preflight_run_id}") +def production_preflight_detail(preflight_run_id: str, request: Request) -> Dict[str, Any]: + try: + return request.app.state.production_preflight_service.run_detail(preflight_run_id=preflight_run_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "production_preflight_missing", "reason": str(exc)}) from exc + + +@router.get("/production-preflight/{preflight_run_id}/report") +def production_preflight_report(preflight_run_id: str, request: Request) -> Dict[str, Any]: + try: + return request.app.state.production_preflight_service.report(preflight_run_id=preflight_run_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "production_preflight_missing", "reason": str(exc)}) from exc + + +@router.get("/launch-week-pack") +def launch_week_pack(request: Request) -> Dict[str, Any]: + return request.app.state.production_launch_week_pack_service.latest_pack() + + +@router.get("/launch-handshake-pack") +def launch_handshake_pack(request: Request) -> Dict[str, Any]: + return request.app.state.production_handshake_pack_service.latest_pack() + + +@router.get("/wave-activation") +def list_wave_activation(request: Request, launch_wave: Optional[str] = None) -> Dict[str, Any]: + return request.app.state.wave_activation_controller_service.summary(launch_wave=launch_wave) + + +@router.get("/wave-activation/{launch_wave}") +def wave_activation_detail(launch_wave: str, request: Request) -> Dict[str, Any]: + return request.app.state.wave_activation_controller_service.evaluate(launch_wave=launch_wave) + + +@router.post("/wave-activation/{launch_wave}/arm") +def arm_wave_activation(launch_wave: str, payload: WaveActivationRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + return request.app.state.wave_activation_controller_service.arm( + actor_id=str(actor["actor_id"]), + actor_role=str(actor["actor_role"]), + launch_wave=launch_wave, + ) + + +@router.post("/wave-activation/{launch_wave}/evaluate") +def evaluate_wave_activation(launch_wave: str, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + return request.app.state.wave_activation_controller_service.evaluate( + launch_wave=launch_wave, + actor_id=str(actor["actor_id"]), + actor_role=str(actor["actor_role"]), + ) + + +@router.post("/wave-activation/{launch_wave}/rollback-watch") +def mark_wave_activation_rollback_watch(launch_wave: str, payload: WaveActivationRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + return request.app.state.wave_activation_controller_service.mark_rollback_watch( + actor_id=str(actor["actor_id"]), + actor_role=str(actor["actor_role"]), + launch_wave=launch_wave, + note=payload.note, + ) + + +@router.get("/launch-command-center") +def launch_command_center(request: Request, launch_wave: Optional[str] = None) -> Dict[str, Any]: + return request.app.state.launch_command_center_service.command_center(launch_wave=launch_wave) + + +@router.get("/production-acceptance") +def list_production_acceptance( + request: Request, + launch_wave: Optional[str] = None, + status: Optional[str] = None, + limit: int = 50, +) -> Dict[str, Any]: + return request.app.state.production_acceptance_service.list_acceptance_records( + launch_wave=launch_wave, + status=status, + limit=limit, + ) + + +@router.post("/production-acceptance/generate") +def generate_production_acceptance(payload: ProductionAcceptanceGenerateRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + try: + return request.app.state.production_acceptance_service.generate_acceptance_record( + actor_id=str(actor["actor_id"]), + actor_role=str(actor["actor_role"]), + account_id=payload.account_id, + launch_wave=payload.launch_wave, + signoff_id=payload.signoff_id, + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "production_acceptance_missing_dependency", "reason": str(exc)}) from exc + + +@router.get("/production-acceptance/{acceptance_record_id}") +def production_acceptance_detail(acceptance_record_id: str, request: Request) -> Dict[str, Any]: + try: + return request.app.state.production_acceptance_service.acceptance_record_detail( + acceptance_record_id=acceptance_record_id + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "production_acceptance_missing", "reason": str(exc)}) from exc + + +@router.get("/launch-waves") +def list_launch_waves(request: Request, launch_wave: Optional[str] = None, status: Optional[str] = None, limit: int = 50) -> Dict[str, Any]: + records = request.app.state.production_acceptance_service.list_acceptance_records( + launch_wave=launch_wave, + status=status, + limit=limit, + ) + return { + "launch_waves": records["launch_waves"], + "summary": records["summary"], + } + + +@router.get("/launch-week-pack") +def launch_week_pack(request: Request) -> Dict[str, Any]: + return request.app.state.production_launch_week_pack_service.latest_pack() + + +@router.post("/launch-waves/{launch_wave}/status") +def update_launch_wave_status(launch_wave: str, payload: LaunchWaveStatusUpdateRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + return request.app.state.production_acceptance_service.update_launch_wave_status( + actor_id=str(actor["actor_id"]), + actor_role=str(actor["actor_role"]), + launch_wave=launch_wave, + status=payload.status, + note=payload.note, + ) + + +@router.post("/customer-success/snapshots/sync") +def sync_customer_success(payload: CustomerSuccessSyncRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + return request.app.state.customer_success_reporting_service.sync_snapshots( + actor_id=str(actor["actor_id"]), + actor_role=str(actor["actor_role"]), + account_id=payload.account_id, + launch_wave=payload.launch_wave, + ) + + +@router.get("/customer-success") +def list_customer_success(request: Request, account_id: Optional[str] = None, launch_wave: Optional[str] = None, limit: int = 50) -> Dict[str, Any]: + return request.app.state.customer_success_reporting_service.list_customer_success( + account_id=account_id, + launch_wave=launch_wave, + limit=limit, + ) + + +@router.get("/customer-success/report") +def customer_success_launch_wave_report(request: Request, launch_wave: str, view: str = "investor_safe") -> Dict[str, Any]: + try: + return request.app.state.customer_success_reporting_service.report(launch_wave=launch_wave, view=view) + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "customer_success_missing", "reason": str(exc)}) from exc -@router.post("/recovery-drill") -def recovery_drill(payload: RuntimeRecoveryDrillRequest, request: Request) -> Dict[str, Any]: - result = request.app.state.runtime_ops_service.run_recovery_drill( - backup_path=payload.backup_path, - output_dir=payload.output_dir, - ) - request.app.state.analytics_service.track( - "runtime_recovery_drill_ran", - payload_json=result, - ) - return {"recovery_drill": result} +@router.get("/customer-success/{account_id}") +def customer_success_detail(account_id: str, request: Request) -> Dict[str, Any]: + return request.app.state.customer_success_reporting_service.detail(account_id=account_id) -@router.post("/jobs/runtime-restores") -def enqueue_runtime_restore_job( - payload: AsyncRuntimeRestoreJobRequest, - background_tasks: BackgroundTasks, - request: Request, -) -> Dict[str, Any]: +@router.get("/customer-success/{account_id}/report") +def customer_success_account_report(account_id: str, request: Request, view: str = "internal") -> Dict[str, Any]: try: - actor = _require_restore_admin(request) - job = request.app.state.async_job_service.enqueue_job( - job_type="runtime_restore", - payload={ - "request_id": payload.request_id, - "requested_by": str(actor["actor_id"]), - }, - requested_by=str(actor["actor_id"]), - account_id=payload.account_id, - schedule=background_tasks.add_task, - ) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) - return {"job": job} + return request.app.state.customer_success_reporting_service.report(account_id=account_id, view=view) + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "customer_success_missing", "reason": str(exc)}) from exc -@router.post("/runtime-backups") -def runtime_backup(payload: RuntimeBackupRequest, request: Request) -> Dict[str, Any]: - result = request.app.state.runtime_ops_service.create_backup( - label=payload.label, - output_dir=payload.output_dir, - dry_run=payload.dry_run, +@router.post("/launch-ledger/sync") +def sync_launch_ledger(payload: LaunchLedgerSyncRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + return request.app.state.production_launch_ledger_service.sync( + actor_id=str(actor["actor_id"]), + actor_role=str(actor["actor_role"]), + launch_wave=payload.launch_wave, ) - request.app.state.analytics_service.track( - "runtime_backup_created", - payload_json=result, + + +@router.get("/launch-ledger") +def list_launch_ledger(request: Request, launch_wave: Optional[str] = None, limit: int = 200) -> Dict[str, Any]: + return request.app.state.production_launch_ledger_service.list_events(launch_wave=launch_wave, limit=limit) + + +@router.get("/launch-ledger/{launch_wave}") +def launch_ledger_detail(launch_wave: str, request: Request, limit: int = 200) -> Dict[str, Any]: + return request.app.state.production_launch_ledger_service.list_events(launch_wave=launch_wave, limit=limit) + + +@router.get("/postmortem-pack") +def build_postmortem_pack(request: Request, launch_wave: str, account_id: Optional[str] = None) -> Dict[str, Any]: + return request.app.state.production_launch_ledger_service.build_postmortem_pack( + launch_wave=launch_wave, + account_id=account_id, ) - return {"backup": result} -@router.post("/runtime-restore") -def runtime_restore(payload: RuntimeRestoreRequest, request: Request) -> Dict[str, Any]: - result = request.app.state.runtime_ops_service.restore_backup( - backup_path=payload.backup_path, - dry_run=payload.dry_run, +@router.post("/go-live-day/run") +def run_go_live_day(payload: GoLiveDayRunRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + return request.app.state.go_live_day_runner_service.run( + actor_id=str(actor["actor_id"]), + actor_role=str(actor["actor_role"]), + launch_wave=payload.launch_wave, + signoff_id=payload.signoff_id, + account_id=payload.account_id, ) - request.app.state.analytics_service.track( - "runtime_restore_applied" if not payload.dry_run else "runtime_restore_planned", - payload_json=result, + + +@router.get("/go-live-day/{run_id}") +def go_live_day_detail(run_id: str, request: Request) -> Dict[str, Any]: + try: + return request.app.state.go_live_day_runner_service.detail(run_id=run_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "go_live_day_run_missing", "reason": str(exc)}) from exc + + +@router.post("/launch-week-guard/sync") +def sync_launch_week_guard(payload: LaunchWeekGuardSyncRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, None) + return request.app.state.launch_week_guard_service.sync( + actor_id=str(actor["actor_id"]), + actor_role=str(actor["actor_role"]), + launch_wave=payload.launch_wave, ) - return {"restore": result} -@router.get("/subscriptions") -def list_subscriptions(account_id: Optional[str] = None, status: Optional[str] = None, request: Request = None) -> Dict[str, Any]: - return request.app.state.billing_service.list_subscriptions(account_id=account_id, status=status) +@router.get("/launch-week-guard") +def list_launch_week_guard(request: Request, launch_wave: Optional[str] = None) -> Dict[str, Any]: + return request.app.state.launch_week_guard_service.list_runs(launch_wave=launch_wave) -@router.get("/entitlements") -def list_entitlements(account_id: Optional[str] = None, reader_id: Optional[str] = None, world_id: Optional[str] = None, request: Request = None) -> Dict[str, Any]: - resolved_account_id = request.app.state.billing_service.resolve_account_id(account_id=account_id, reader_id=reader_id) - return request.app.state.billing_service.entitlement_audit(account_id=resolved_account_id, world_id=world_id) +@router.get("/launch-week-guard/{launch_wave}") +def launch_week_guard_detail(launch_wave: str, request: Request) -> Dict[str, Any]: + return request.app.state.launch_week_guard_service.detail(launch_wave=launch_wave) -@router.get("/accounts/{account_id}") -def account_detail(account_id: str, request: Request, limit: int = 10) -> Dict[str, Any]: - return request.app.state.billing_service.account_detail(account_id=account_id, limit=limit) +@router.get("/first-customer-success-pack/{launch_wave}") +def first_customer_success_pack(launch_wave: str, request: Request) -> Dict[str, Any]: + detail = request.app.state.launch_week_guard_service.detail(launch_wave=launch_wave) + pack = detail.get("first_customer_success_pack") + if not pack: + raise HTTPException(status_code=404, detail={"code": "first_customer_success_pack_missing", "reason": launch_wave}) + return detail -@router.get("/accounts/{account_id}/workspace") -def account_workspace(account_id: str, request: Request, limit: int = 12) -> Dict[str, Any]: - return request.app.state.ops_account_workspace_service.account_workspace(account_id=account_id, limit=limit) +@router.get("/lifecycle-automation") +def lifecycle_automation_state(request: Request, account_id: Optional[str] = None, limit: int = 100) -> Dict[str, Any]: + return request.app.state.commercial_lifecycle_automation_service.list_account_state(account_id=account_id, limit=limit) + + +@router.post("/lifecycle-automation/sync") +def sync_lifecycle_automation(payload: LifecycleAutomationSyncRequest, request: Request) -> Dict[str, Any]: + _require_ops_reviewer(request, None) + return request.app.state.commercial_lifecycle_automation_service.sync_account(account_id=payload.account_id) @router.get("/accounts/{account_id}/issues") @@ -1376,13 +2576,16 @@ def update_ops_alert_status( payload: AlertStatusRequest, request: Request, ) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, payload.reviewer_id) try: detail = request.app.state.ops_alerting_service.update_alert_status( alert_id, status=payload.status, - reviewer_id=payload.reviewer_id, + reviewer_id=actor["actor_id"], + actor_role=actor["actor_role"], note=payload.note, account_id=payload.account_id, + source_surface="ops_api", ) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) @@ -1439,6 +2642,73 @@ def list_governance_cases( ) +@router.get("/governance/workload") +def governance_owner_workload( + request: Request, + status: Optional[str] = None, + owner_id: Optional[str] = None, + case_type: Optional[str] = None, + severity: Optional[str] = None, + target_type: Optional[str] = None, + has_active_restriction: Optional[bool] = None, + overdue_only: bool = False, + unassigned_only: bool = False, + search: Optional[str] = None, + selected_case_ids: Optional[str] = None, + limit: int = 100, +) -> Dict[str, Any]: + actor = _require_ops_reviewer(request) + del actor + return request.app.state.governance_service.owner_workload( + status=status, + owner_id=owner_id, + case_type=case_type, + severity=severity, + target_type=target_type, + has_active_restriction=has_active_restriction, + overdue_only=overdue_only, + unassigned_only=unassigned_only, + search=search, + selected_case_ids=[item.strip() for item in str(selected_case_ids or "").split(",") if item.strip()], + limit=limit, + ) + + +@router.put("/governance/capacity/owners/{owner_id}") +def update_governance_capacity_override( + owner_id: str, + payload: GovernanceCapacityOverrideRequest, + request: Request, +) -> Dict[str, Any]: + actor = _require_ops_roles( + request, + allowed_roles={"admin"}, + fallback_actor_id=payload.reviewer_id, + missing_reason="governance_capacity_admin_required", + forbidden_reason="governance_capacity_admin_required", + ) + try: + override = request.app.state.governance_service.update_capacity_override( + owner_id, + capacity_units_per_day=payload.capacity_units_per_day, + critical_case_limit=payload.critical_case_limit, + active_restriction_limit=payload.active_restriction_limit, + sla_hours=payload.sla_hours, + role_multiplier=payload.role_multiplier, + enabled=payload.enabled, + clear_override=payload.clear_override, + reviewer_id=actor["actor_id"], + actor_role=actor["actor_role"], + note=payload.note, + source_surface="ops_api", + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return {"override": override} + + @router.get("/governance/cases/{case_id}") def governance_case_detail(case_id: str, request: Request) -> Dict[str, Any]: actor = _ops_actor(request) @@ -1452,12 +2722,34 @@ def governance_case_detail(case_id: str, request: Request) -> Dict[str, Any]: raise HTTPException(status_code=404, detail=str(exc)) +@router.get("/governance/cases/{case_id}/restriction-history") +def governance_case_restriction_history(case_id: str, request: Request, limit: int = 20) -> Dict[str, Any]: + actor = _ops_actor(request) + try: + request.app.state.ops_permission_policy.authorize_read( + actor_id=actor["actor_id"], + actor_role=actor["actor_role"], + missing_reason="reviewer_identity_required", + ) + return request.app.state.governance_service.restriction_history(case_id, limit=max(1, min(100, int(limit or 20)))) + except PermissionError as exc: + raise HTTPException(status_code=401, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=403, detail=str(exc)) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + + @router.post("/governance/cases") def create_governance_case(payload: GovernanceCaseRequest, request: Request) -> Dict[str, Any]: actor = _require_ops_reviewer(request, payload.reviewer_id) try: case = request.app.state.governance_service.create_case( - _apply_ops_identity(request, payload.model_dump()) + { + **_apply_ops_identity(request, payload.model_dump()), + "actor_role": actor["actor_role"], + "source_surface": "ops_api", + } ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) @@ -1472,6 +2764,40 @@ def create_governance_case(payload: GovernanceCaseRequest, request: Request) -> return {"case": case} +@router.post("/governance/cases/{case_id}/restriction") +def apply_governance_case_restriction( + case_id: str, + payload: GovernanceCaseRestrictionRequest, + request: Request, +) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, payload.reviewer_id) + try: + case = request.app.state.governance_service.apply_case_restriction( + case_id, + restriction_type=payload.restriction_type, + reviewer_id=actor["actor_id"], + actor_role=actor["actor_role"], + restriction_reason=payload.restriction_reason, + expires_at=payload.expires_at, + source_surface="ops_api", + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) + request.app.state.analytics_service.track( + "governance_restriction_applied", + reader_id=case.get("account_id"), + account_id=case.get("account_id"), + world_id=case.get("world_id"), + world_version_id=case.get("world_version_id"), + payload_json=case, + ) + return {"case": case} + + @router.post("/governance/cases/{case_id}/assign") def assign_governance_case( case_id: str, @@ -1484,8 +2810,10 @@ def assign_governance_case( case_id, owner_id=payload.owner_id, reviewer_id=actor["actor_id"], + actor_role=actor["actor_role"], due_at=payload.due_at, note=payload.note, + source_surface="ops_api", ) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) @@ -1507,10 +2835,12 @@ def append_governance_case_evidence( case = request.app.state.governance_service.append_case_evidence( case_id, reviewer_id=actor["actor_id"], + actor_role=actor["actor_role"], title=payload.title, preview=payload.preview, ref_id=payload.ref_id, kind=payload.kind, + source_surface="ops_api", ) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) @@ -1537,10 +2867,14 @@ def list_governance_restrictions( @router.post("/governance/restrictions") def apply_governance_restriction(payload: GovernanceRestrictionRequest, request: Request) -> Dict[str, Any]: - _require_ops_reviewer(request, payload.reviewer_id) + actor = _require_ops_reviewer(request, payload.reviewer_id) try: case = request.app.state.governance_service.apply_restriction( - _apply_ops_identity(request, payload.model_dump()) + { + **_apply_ops_identity(request, payload.model_dump()), + "actor_role": actor["actor_role"], + "source_surface": "ops_api", + } ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) @@ -1566,7 +2900,9 @@ def release_governance_restriction( case = request.app.state.governance_service.release_restriction( restriction_id, reviewer_id=actor["actor_id"], + actor_role=actor["actor_role"], release_reason=payload.release_reason, + source_surface="ops_api", ) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) @@ -1583,6 +2919,40 @@ def release_governance_restriction( return {"case": case} +@router.patch("/governance/restrictions/{restriction_id}") +def update_governance_restriction( + restriction_id: str, + payload: GovernanceRestrictionUpdateRequest, + request: Request, +) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, payload.reviewer_id) + try: + case = request.app.state.governance_service.update_restriction( + restriction_id, + reviewer_id=actor["actor_id"], + actor_role=actor["actor_role"], + restriction_type=payload.restriction_type, + restriction_reason=payload.restriction_reason, + expires_at=payload.expires_at, + source_surface="ops_api", + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) + request.app.state.analytics_service.track( + "governance_restriction_updated", + reader_id=case.get("account_id"), + account_id=case.get("account_id"), + world_id=case.get("world_id"), + world_version_id=case.get("world_version_id"), + payload_json=case, + ) + return {"case": case} + + @router.post("/governance/cases/{case_id}/status") def update_governance_case_status(case_id: str, payload: GovernanceCaseStatusRequest, request: Request) -> Dict[str, Any]: actor = _require_ops_reviewer(request, payload.reviewer_id) @@ -1591,8 +2961,10 @@ def update_governance_case_status(case_id: str, payload: GovernanceCaseStatusReq case_id, status=payload.status, reviewer_id=actor["actor_id"], + actor_role=actor["actor_role"], resolution_notes=payload.resolution_notes, disposition=payload.disposition, + source_surface="ops_api", ) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) @@ -1611,6 +2983,39 @@ def update_governance_case_status(case_id: str, payload: GovernanceCaseStatusReq return {"case": case} +@router.post("/governance/cases/bulk/preview") +def governance_bulk_action_preview(payload: GovernanceBulkActionRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, payload.reviewer_id) + try: + return request.app.state.governance_service.bulk_action_preview( + case_ids=list(payload.case_ids or []), + action=payload.action, + payload=payload.model_dump(), + reviewer_id=actor["actor_id"], + actor_role=actor["actor_role"], + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.post("/governance/cases/bulk/execute") +def governance_bulk_action_execute(payload: GovernanceBulkActionRequest, request: Request) -> Dict[str, Any]: + actor = _require_ops_reviewer(request, payload.reviewer_id) + try: + return request.app.state.governance_service.bulk_action_execute( + case_ids=list(payload.case_ids or []), + action=payload.action, + payload=payload.model_dump(), + reviewer_id=actor["actor_id"], + actor_role=actor["actor_role"], + source_surface="ops_api", + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) + + @router.get("/export/governance-audit") def export_governance_audit( request: Request, @@ -1743,6 +3148,21 @@ def reconcile_subscription(subscription_id: str, payload: BillingLifecycleReplay return reconciled +@router.post("/accounts/{account_id}/billing/reconcile") +def reconcile_account_billing(account_id: str, payload: AccountBillingReconcileRequest, request: Request) -> Dict[str, Any]: + reconciled = request.app.state.billing_service.reconcile_account_billing( + account_id=account_id, + provider=payload.provider, + ) + request.app.state.analytics_service.track( + "account_billing_reconcile_requested", + reader_id=account_id, + account_id=account_id, + payload_json={"requested_by": payload.requested_by, **reconciled}, + ) + return reconciled + + @router.post("/subscriptions/{subscription_id}/retry-payment") def ops_retry_subscription_payment(subscription_id: str, payload: BillingRetryRequest, request: Request) -> Dict[str, Any]: try: @@ -1884,13 +3304,21 @@ def eval_metrics_world_version_detail(world_version_id: str, request: Request) - @router.get("/cross-pack-quality") -def cross_pack_quality(request: Request) -> Dict[str, Any]: +def cross_pack_quality( + request: Request, + validate_strategy_bundle: bool = False, + strategy_bundle_id: Optional[str] = None, + weakest_limit: int = 3, +) -> Dict[str, Any]: return run_benchmark( repository=request.app.state.repository, golden_dir=request.app.state.base_dir / "tests" / "golden_routes", baseline=json.loads( (request.app.state.base_dir / "tests" / "benchmark_baseline.json").read_text(encoding="utf-8") ), + validate_strategy_bundle=validate_strategy_bundle, + strategy_bundle_id=strategy_bundle_id, + weakest_limit=weakest_limit, ) @@ -2143,6 +3571,48 @@ def review_sample_backlog( return {"backlog": summary["review_sample_backlog"]} +@router.get("/longform-250-human-review-closeout") +def longform_250_human_review_closeout( + request: Request, + world_id: Optional[str] = None, + world_version_id: Optional[str] = None, + limit: Optional[int] = None, +) -> Dict[str, Any]: + return request.app.state.training_signal_service.longform_250_human_review_closeout( + world_id=world_id, + world_version_id=world_version_id, + limit=limit, + ) + + +@router.get("/longform-500-human-review-closeout") +def longform_500_human_review_closeout( + request: Request, + world_id: Optional[str] = None, + world_version_id: Optional[str] = None, + limit: Optional[int] = None, +) -> Dict[str, Any]: + return request.app.state.training_signal_service.longform_500_human_review_closeout( + world_id=world_id, + world_version_id=world_version_id, + limit=limit, + ) + + +@router.get("/longform-1000-human-review-closeout") +def longform_1000_human_review_closeout( + request: Request, + world_id: Optional[str] = None, + world_version_id: Optional[str] = None, + limit: Optional[int] = None, +) -> Dict[str, Any]: + return request.app.state.training_signal_service.longform_1000_human_review_closeout( + world_id=world_id, + world_version_id=world_version_id, + limit=limit, + ) + + @router.get("/issue-fix-pair-backlog") def issue_fix_pair_backlog( request: Request, diff --git a/src/narrativeos/api/quantum_compat.py b/src/narrativeos/api/quantum_compat.py new file mode 100644 index 0000000..db15597 --- /dev/null +++ b/src/narrativeos/api/quantum_compat.py @@ -0,0 +1,5124 @@ +from __future__ import annotations + +import copy +import json +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional +from urllib.parse import quote, quote_plus +from uuid import uuid4 + +from fastapi import APIRouter, Request, Response +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from ..models import NarrativeState +from ..services.auth import AuthServiceError +from ..services.choice_semantics import build_choice_impacts +from ..services.reader_generation_jobs import READER_GENERATION_JOB_TYPE +from ..services.sessions import ReaderContinueCommand, build_reader_continuity_contract +from .auth import _auth_export_payload + + +router = APIRouter(prefix="/api/v1", tags=["quantum-compat"]) + + +class QuantumAuthRegisterRequest(BaseModel): + username: str + email: str + password: str + displayName: str + + +class QuantumAuthLoginRequest(BaseModel): + identifier: str + password: str + + +class QuantumAuthRefreshRequest(BaseModel): + refreshToken: str + + +class QuantumAuthProfileUpdateRequest(BaseModel): + displayName: Optional[str] = None + avatar: Optional[str] = None + email: Optional[str] = None + + +class QuantumMembershipSubscribeRequest(BaseModel): + planId: str + + +class QuantumInkPurchaseRequest(BaseModel): + packageId: str + + +class QuantumSettingsPreferencesRequest(BaseModel): + immersiveEffects: Optional[bool] = None + autoRenderArt: Optional[bool] = None + privacyMode: Optional[bool] = None + streamSpeed: Optional[int] = None + particleDensity: Optional[int] = None + fontSize: Optional[str] = None + theme: Optional[str] = None + + +class QuantumSoulPreferencesRequest(BaseModel): + genres: Optional[List[str]] = None + styles: Optional[List[str]] = None + privacyMode: Optional[str] = None + + +class QuantumLibraryFollowRequest(BaseModel): + targetType: str + targetId: str + + +class QuantumSettingsAccountPasswordChangeRequest(BaseModel): + currentPassword: str + newPassword: str + + +class QuantumSettingsAccountEmailChangeRequest(BaseModel): + newEmail: str + currentPassword: str + + +class QuantumSettingsAccountEmailConfirmRequest(BaseModel): + token: str + + +class QuantumInkCheckoutCompleteRequest(BaseModel): + accountId: Optional[str] = None + + +class QuantumLegacyCheckoutSessionRequest(BaseModel): + packageId: str + amount: Optional[float] = None + currency: str = "usd" + price: Optional[float] = None + + +class QuantumLegacySubscriptionSessionRequest(BaseModel): + tierId: str + currency: str = "usd" + + +class QuantumShowcaseCommentRequest(BaseModel): + content: str + + +class QuantumShowcaseTipRequest(BaseModel): + amount: int + + +class QuantumStoryImportStartRequest(BaseModel): + targetType: str + targetId: str + deferBootstrap: Optional[bool] = None + + +class QuantumStoryChoiceRequest(BaseModel): + sessionId: str + choiceId: str + nodeId: str + + +class QuantumStoryBookmarkRequest(BaseModel): + nodeId: str + + +class QuantumOpsReviewAssignRequest(BaseModel): + pass + + +class QuantumOpsReviewStatusRequest(BaseModel): + status: str + + +class QuantumOpsReviewDecisionRequest(BaseModel): + decision: str + + +class QuantumOpsGrantSubscriptionRequest(BaseModel): + tierId: str + + +class QuantumOpsGrantWalletRequest(BaseModel): + walletType: str + amount: float + tierId: Optional[str] = None + + +class QuantumOpsAlertMutationRequest(BaseModel): + accountId: Optional[str] = None + note: Optional[str] = None + + +class QuantumOpsGovernanceAssignRequest(BaseModel): + accountId: Optional[str] = None + note: Optional[str] = None + ownerId: Optional[str] = None + dueAt: Optional[str] = None + + +class QuantumOpsGovernanceStatusRequest(BaseModel): + accountId: Optional[str] = None + status: str + resolutionNotes: Optional[str] = None + disposition: Optional[str] = None + + +class QuantumOpsGovernanceEvidenceRequest(BaseModel): + accountId: Optional[str] = None + title: str + preview: str + refId: Optional[str] = None + kind: Optional[str] = None + + +class QuantumOpsGovernanceRestrictionReleaseRequest(BaseModel): + accountId: Optional[str] = None + releaseReason: str + + +class QuantumOpsGovernanceRestrictionUpdateRequest(BaseModel): + accountId: Optional[str] = None + restrictionType: Optional[str] = None + restrictionReason: Optional[str] = None + expiresAt: Optional[str] = None + + +class QuantumOpsGovernanceCaseRestrictionRequest(BaseModel): + accountId: Optional[str] = None + restrictionType: str + restrictionReason: Optional[str] = None + expiresAt: Optional[str] = None + + +class QuantumOpsGovernanceApplyRestrictionRequest(BaseModel): + accountId: Optional[str] = None + restrictionType: str + summary: str + description: Optional[str] = None + severity: Optional[str] = None + expiresAt: Optional[str] = None + restrictionReason: Optional[str] = None + supportIssueIds: Optional[List[str]] = None + + +class QuantumOpsGovernanceRestrictionConfig(BaseModel): + enabled: bool = False + restrictionType: Optional[str] = None + expiresAt: Optional[str] = None + restrictionReason: Optional[str] = None + + +class QuantumOpsGovernanceCreateCaseRequest(BaseModel): + accountId: Optional[str] = None + caseType: str + targetType: str + targetId: Optional[str] = None + dueAt: Optional[str] = None + severity: Optional[str] = None + summary: str + description: Optional[str] = None + policyLabels: Optional[List[str]] = None + supportIssueIds: Optional[List[str]] = None + applyRestriction: Optional[QuantumOpsGovernanceRestrictionConfig] = None + + +class QuantumOpsGovernanceBulkActionRequest(BaseModel): + caseIds: List[str] + action: str + ownerId: Optional[str] = None + ownerAssignments: Optional[Dict[str, str]] = None + dueAt: Optional[str] = None + note: Optional[str] = None + status: Optional[str] = None + resolutionNotes: Optional[str] = None + disposition: Optional[str] = None + policyLabels: Optional[List[str]] = None + restrictionType: Optional[str] = None + restrictionReason: Optional[str] = None + expiresAt: Optional[str] = None + + +class QuantumOpsGovernanceCapacityOverrideRequest(BaseModel): + capacityUnitsPerDay: Optional[float] = None + criticalCaseLimit: Optional[int] = None + activeRestrictionLimit: Optional[int] = None + slaHours: Optional[int] = None + roleMultiplier: Optional[float] = None + enabled: Optional[bool] = None + clearOverride: bool = False + note: Optional[str] = None + + +class QuantumStudioNodeCreateRequest(BaseModel): + title: str + type: str + x: int + y: int + description: str = "" + parentId: Optional[str] = None + + +class QuantumStudioNodeUpdateRequest(BaseModel): + title: Optional[str] = None + x: Optional[int] = None + y: Optional[int] = None + description: Optional[str] = None + + +class QuantumStudioPreviewRequest(BaseModel): + pass + + +class QuantumStudioEngineRequest(BaseModel): + engine: str + + +class QuantumStudioWorldRulesRequest(BaseModel): + ruleIds: List[str] + + +QUANTUM_TIER_MAP = { + "play_pass": "observer", + "creator_pass": "intervener", + "studio_pass": "savior", + "observer": "observer", + "intervener": "intervener", + "savior": "savior", +} + +QUANTUM_PLAN_CATALOG = { + "play_pass": { + "plan_id": "plan_observer", + "frontend_tier": "observer", + "name": "Observer", + }, + "creator_pass": { + "plan_id": "plan_intervener", + "frontend_tier": "intervener", + "name": "Intervener", + }, + "studio_pass": { + "plan_id": "plan_savior", + "frontend_tier": "savior", + "name": "Savior", + }, +} + +QUANTUM_PLAN_ALIAS_TO_TIER = { + "observer": "play_pass", + "plan_observer": "play_pass", + "play_pass": "play_pass", + "intervener": "creator_pass", + "plan_intervener": "creator_pass", + "creator_pass": "creator_pass", + "savior": "studio_pass", + "plan_savior": "studio_pass", + "studio_pass": "studio_pass", +} + +QUANTUM_STORY_SHARE_TTL_DAYS = 7 + +QUANTUM_INK_PACKAGES: List[Dict[str, Any]] = [ + {"id": "ink_500", "amount": 500, "bonus": 0, "price": 0.99, "isRecommended": False}, + {"id": "ink_2500", "amount": 2500, "bonus": 100, "price": 4.99, "isRecommended": False}, + {"id": "ink_5000", "amount": 5000, "bonus": 500, "price": 9.99, "isRecommended": True}, + {"id": "ink_12000", "amount": 12000, "bonus": 2000, "price": 19.99, "isRecommended": False}, + {"id": "ink_30000", "amount": 30000, "bonus": 7500, "price": 49.99, "isRecommended": False}, + {"id": "ink_80000", "amount": 80000, "bonus": 25000, "price": 99.99, "isRecommended": False}, +] + +QUANTUM_STUDIO_ENGINES = ["balanced", "effect", "speed", "cost"] +QUANTUM_LIBRARY_FILTERS = {"recent", "favorites", "following", "completed"} +QUANTUM_SHOWCASE_SORTS = {"hot", "new", "ongoing"} +QUANTUM_STORY_BRANCH_TYPES = ["rational", "emotional", "adventurous", "fateful"] +QUANTUM_STORY_DEVIATION_INCREMENTS = [5, 15, 25, 35] + + +def _is_public_catalog_visible(metadata: Dict[str, Any]) -> bool: + if str(metadata.get("catalog_role") or "").strip() == "template": + return False + if metadata.get("public_catalog_visible") is False: + return False + return True + + +def _timestamp() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _success(*, data: Any, message: str = "success", code: int = 200) -> Dict[str, Any]: + return { + "code": code, + "data": data, + "message": message, + "timestamp": _timestamp(), + } + + +def _error(*, status_code: int, message: str, code: Optional[int] = None, data: Any = None) -> JSONResponse: + return JSONResponse( + status_code=status_code, + content={ + "code": code or status_code, + "data": data, + "message": message, + "timestamp": _timestamp(), + }, + ) + + +def _request_token(request: Request) -> Optional[str]: + return request.app.state.auth_service.extract_request_token( + authorization=request.headers.get("Authorization"), + cookies=request.cookies, + ) + +def _maybe_identity(request: Request) -> Optional[Dict[str, Any]]: + raw_token = _request_token(request) + if not raw_token: + return None + try: + return request.app.state.auth_service.resolve_bearer_token(raw_token) + except (PermissionError, KeyError): + return None + + +def _resolve_login_actor_id(request: Request, identifier: str) -> str: + return request.app.state.auth_service.resolve_actor_id_from_identifier(identifier) + + +def _ensure_registration_available(request: Request, *, username: str, email: str) -> None: + try: + request.app.state.repository.get_auth_identity(username) + except KeyError: + pass + else: + raise ValueError("username_already_registered") + existing = request.app.state.repository.get_auth_identity_by_account_id(email, default=None) + if existing is not None: + raise ValueError("email_already_registered") + + +def _frontend_membership_tier(tier_id: Optional[str]) -> str: + normalized = str(tier_id or "").strip() + if not normalized: + return "free" + return QUANTUM_TIER_MAP.get(normalized, "free") + + +def _quantum_checkout_error(exc: Exception) -> JSONResponse: + reason = str(exc) or "compat_checkout_failed" + status_code = 400 + if reason in {"checkout_restricted", "email_verification_required_for_billing"}: + status_code = 403 + elif reason in {"stripe_not_configured", "stripe_sdk_missing"}: + status_code = 503 + return _error(status_code=status_code, message=reason) + + +def _quantum_identity_account_id(identity: Dict[str, Any]) -> str: + return str(identity.get("account_id") or identity.get("actor_id") or "").strip() + + +def _quantum_identity_actor_id(identity: Dict[str, Any]) -> str: + return str(identity.get("actor_id") or "").strip() + + +def _require_identity(request: Request) -> Dict[str, Any]: + identity = _maybe_identity(request) + if identity is None: + raise PermissionError("auth_required") + return identity + + +def _require_quantum_ops_actor(request: Request) -> Dict[str, Any]: + raw_token = _request_token(request) + if not raw_token: + raise PermissionError("auth_required") + try: + identity = request.app.state.auth_service.resolve_bearer_token(raw_token) + except (PermissionError, KeyError) as exc: + raise PermissionError(str(exc) or "auth_token_invalid") from exc + try: + request.app.state.ops_permission_policy.authorize_read( + actor_id=str(identity.get("actor_id") or "").strip() or None, + actor_role=str(identity.get("actor_role") or "").strip() or None, + ) + except PermissionError as exc: + raise ValueError(str(exc) or "ops_actor_forbidden") from exc + return identity + + +def _quantum_ops_world_strip(request: Request, *, world_ids: List[str], limit: int = 3) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + seen: set[str] = set() + for world_id in world_ids: + normalized = str(world_id or "").strip() + if not normalized or normalized in seen: + continue + seen.add(normalized) + try: + items.append(request.app.state.review_service.world_status(normalized)) + except Exception: + continue + if len(items) >= limit: + break + return items + + +def _quantum_ops_reviewer_workspace_payload( + request: Request, + *, + selected_review_item_id: Optional[str], + limit: int, +) -> Dict[str, Any]: + review_hub = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "filters": {"limit": limit, "source": "deferred"}, + "summary": {}, + "triage": {}, + "items": [], + } + selected_id = str(selected_review_item_id or "").strip() + selected_review_item = None + if selected_id: + try: + selected_review_item = request.app.state.ops_review_hub_service.review_item_detail_cached(selected_id).get("review_item") + except KeyError: + selected_review_item = None + return { + "reviewHub": review_hub, + "reviewQueue": [], + "selectedReviewItemId": selected_id or None, + "selectedReviewItem": selected_review_item, + "selectedReviewWork": None, + "supportedMutations": { + "assign": True, + "statuses": ["triaged", "in_review"], + "decisions": ["approve", "needs_changes", "block", "resolve", "dismiss"], + }, + "selectedReviewItemAllowedActions": list((selected_review_item or {}).get("allowed_actions") or []), + "worldStrip": [], + } + + +def _quantum_ops_runtime_workspace_payload( + request: Request, + *, + account_id: Optional[str], + world_id: Optional[str], + limit: int, +) -> Dict[str, Any]: + runtime_incident_snapshot = request.app.state.observability_service.runtime_incident_snapshot( + account_id=account_id, + limit=limit, + ) + runtime_receipts = request.app.state.observability_service.list_runtime_receipts( + account_id=account_id, + limit=limit, + ) + provider_routing = request.app.state.provider_routing_service.policy_summary() + provider_rollout = request.app.state.provider_rollout_service.summary( + candidate_backend_present=request.app.state.candidate_backend is not None, + renderer_backend_present=request.app.state.renderer_backend is not None, + ) + provider_runtime_metrics = request.app.state.observability_service.provider_runtime_metrics( + account_id=account_id, + limit=max(limit, 24), + ) + story_bootstrap_world_summary = request.app.state.observability_service.story_bootstrap_world_summary(limit=12) + selected_world_id = str(world_id or "").strip() or str((story_bootstrap_world_summary.get("worlds") or [{}])[0].get("worldId") or "").strip() + story_bootstrap_world_detail = None + if selected_world_id: + try: + story_bootstrap_world_detail = request.app.state.observability_service.story_bootstrap_world_detail( + selected_world_id, + limit=12, + ) + except KeyError: + story_bootstrap_world_detail = None + return { + "accountId": account_id, + "worldId": selected_world_id or None, + "runtimeIncidentSnapshot": runtime_incident_snapshot, + "runtimeReceipts": runtime_receipts, + "providerRouting": provider_routing, + "providerRollout": provider_rollout, + "providerRuntimeMetrics": provider_runtime_metrics, + "storyBootstrapWorldSummary": story_bootstrap_world_summary.get("worlds") or [], + "storyBootstrapWorldDetail": story_bootstrap_world_detail, + } + + +def _quantum_ops_account_workspace_payload( + request: Request, + *, + account_id: str, + limit: int, +) -> Dict[str, Any]: + billing = request.app.state.billing_service + subscriptions = billing.monetization.list_subscriptions(account_id=account_id) + subscription = next((item for item in subscriptions if item.get("status") in {"trialing", "active", "past_due"}), None) or (subscriptions[0] if subscriptions else None) + subscription_payload = billing._subscription_snapshot(subscription) if subscription else None + wallets = billing._wallets_for_account(account_id) + entitlement_count = len([item for item in wallets.values() if item]) + subscription_audit = { + "account_id": account_id, + "config_version": "entitlement_matrix_v1", + "audit_summary": { + "entitlement_count": entitlement_count, + "subscription_count": len(subscriptions), + "source": "ops_account_workspace_light", + }, + } + account_detail = { + "account_id": account_id, + "subscription": subscription_payload, + "wallets": wallets, + } + account_workspace = { + "workspace_summary": { + "health_status": "operational" if subscription_payload else "needs_attention", + "recommended_path": "account_light_workspace", + "source": "light", + }, + "operator_timeline": [], + "action_pack": [], + "linked_context": {}, + } + governance_snapshot = { + "account_id": account_id, + "governance_cases": [], + "restriction_summary": {"active_restriction_count": 0}, + "support_summary": {}, + } + available_mutations = [ + { + "actionId": "grant_subscription", + "label": "Grant Subscription", + "handler": "grant_subscription", + "reason": "manual ops grant", + "prefill": {"tier_id": str((subscription_payload or {}).get("tier_id") or "play_pass")}, + }, + { + "actionId": "grant_wallet", + "label": "Grant Wallet", + "handler": "grant_wallet", + "reason": "manual wallet top-up", + "prefill": {"wallet_type": "story_credits", "amount": 10}, + }, + ] + subscription_id = _quantum_ops_primary_subscription_id(account_detail) + if subscription_id: + available_mutations.append( + { + "actionId": "retry_subscription_payment", + "label": "Retry Subscription Payment", + "handler": "retry_subscription_payment", + "reason": "retry current subscription payment", + "prefill": {"subscription_id": subscription_id}, + } + ) + available_mutations.append( + { + "actionId": "reconcile_subscription", + "label": "Reconcile Subscription", + "handler": "reconcile_subscription", + "reason": "refresh current subscription lifecycle snapshot", + "prefill": {"subscription_id": subscription_id}, + } + ) + return { + "accountId": account_id, + "subscriptionAudit": subscription_audit, + "accountDetail": account_detail, + "accountWorkspace": account_workspace, + "governanceSnapshot": governance_snapshot, + "availableMutations": available_mutations, + } + + +def _quantum_ops_release_workspace_payload( + request: Request, + *, + world_id: Optional[str], + limit: int, +) -> Dict[str, Any]: + summary = request.app.state.observability_service.story_bootstrap_world_summary(limit=12) + selected_world_id = str(world_id or "").strip() or str((summary.get("worlds") or [{}])[0].get("worldId") or "").strip() + workspace = None + if selected_world_id: + try: + workspace = request.app.state.ops_release_workspace_service.world_release_workspace(world_id=selected_world_id, limit=max(limit, 12)) + except KeyError: + workspace = None + return { + "worldId": selected_world_id or None, + "releaseWorkspace": workspace, + "worldSummary": summary.get("worlds") or [], + } + + +def _quantum_ops_alerts_workspace_payload( + request: Request, + *, + account_id: Optional[str], + alert_id: Optional[str], + limit: int, +) -> Dict[str, Any]: + resolved_account_id = str(account_id or "").strip() or "ops_remote_acceptance" + light_alert_id = f"quantum_ops_light::{resolved_account_id}" + selected_alert_id = str(alert_id or "").strip() or light_alert_id + audit_trail = request.app.state.repository.list_audit_logs( + object_type="ops_alert", + object_id=selected_alert_id, + limit=20, + ) + action_types = [str(item.get("action_type") or "") for item in audit_trail] + if "ops_alert_resolved" in action_types: + selected_alert_status = "resolved" + elif "ops_alert_acknowledged" in action_types: + selected_alert_status = "acknowledged" + else: + selected_alert_status = "open" + selected_alert = { + "alert_id": selected_alert_id, + "account_id": resolved_account_id, + "category": "runtime", + "severity": "medium", + "status": selected_alert_status, + "source_type": "remote_acceptance", + "summary": "Remote Ops lightweight alert", + "title": "Remote Ops lightweight alert", + "recommended_actions": ["acknowledge_alert", "resolve_alert"], + } + selected_alert_detail = { + "alert": selected_alert, + "runbook": {}, + "standard_response_bundle": {}, + "investigation_bundle": None, + "operator_audit_trail": audit_trail, + "generated_at": datetime.now(timezone.utc).isoformat(), + } + visible_alerts = [selected_alert] if selected_alert_status != "resolved" else [] + selected_alert = dict((selected_alert_detail or {}).get("alert") or {}) + selected_alert_allowed_actions: List[str] = [] + if selected_alert_status == "open": + selected_alert_allowed_actions = ["acknowledge", "resolve"] + elif selected_alert_status == "acknowledged": + selected_alert_allowed_actions = ["resolve"] + return { + "accountId": resolved_account_id, + "alertsSummary": { + "alert_count": len(visible_alerts), + "actionable_alert_count": len(visible_alerts), + "source": "light", + }, + "alerts": visible_alerts, + "selectedAlertId": selected_alert_id or None, + "selectedAlertDetail": selected_alert_detail, + "selectedAlertAllowedActions": selected_alert_allowed_actions, + "selectedAlertOperatorAudit": list((selected_alert_detail or {}).get("operator_audit_trail") or []), + } + + +def _quantum_ops_governance_workspace_payload( + request: Request, + *, + account_id: Optional[str], + case_id: Optional[str], + limit: int, +) -> Dict[str, Any]: + account_snapshot = ( + request.app.state.governance_service.account_snapshot(account_id=account_id, limit=max(limit, 20)) + if account_id + else {"recommended_case_prefills": []} + ) + cases_payload = request.app.state.governance_service.list_cases(account_id=account_id, limit=max(limit, 20)) + restrictions_payload = request.app.state.governance_service.list_restrictions(account_id=account_id, limit=max(limit, 20)) + selected_case_id = str(case_id or "").strip() or str((cases_payload.get("cases") or [{}])[0].get("case_id") or "").strip() + selected_case_detail = None + viewer_identity = _maybe_identity(request) or {} + if selected_case_id: + try: + selected_case_detail = request.app.state.governance_service.case_detail( + selected_case_id, + actor_id=str(viewer_identity.get("actor_id") or "").strip() or None, + actor_role=str(viewer_identity.get("actor_role") or "").strip() or None, + ) + except KeyError: + selected_case_detail = None + permission_summary = dict((selected_case_detail or {}).get("permission_summary") or {}) + workflow_summary = dict((selected_case_detail or {}).get("workflow_summary") or {}) + restriction = dict((selected_case_detail or {}).get("restriction") or {}) or None + owner_roster = request.app.state.governance_service.owner_roster(limit=25) + return { + "accountId": account_id, + "governanceSummary": cases_payload.get("governance_summary") or {}, + "governanceCases": cases_payload.get("cases") or [], + "restrictions": restrictions_payload.get("restrictions") or [], + "restrictionSummary": restrictions_payload.get("restriction_summary") or {}, + "recommendedCasePrefills": list(account_snapshot.get("recommended_case_prefills") or []), + "supportSummary": dict(account_snapshot.get("support_summary") or {}), + "supportIssueRefs": list(account_snapshot.get("support_issue_refs") or []), + "caseTypeCatalog": _quantum_governance_case_type_catalog(), + "targetTypeCatalog": _quantum_governance_target_type_catalog(), + "policyLabelSuggestions": _quantum_governance_policy_label_suggestions(), + "targetResolver": _quantum_governance_target_resolver(request, account_id=account_id, limit=10), + "targetResolverMeta": _quantum_governance_target_resolver_meta(request, account_id=account_id), + "restrictionCatalog": _quantum_governance_restriction_catalog(), + "ownerRoster": [ + { + "actorId": str(item.get("actor_id") or ""), + "displayName": str(item.get("display_name") or item.get("actor_id") or ""), + "actorRole": str(item.get("actor_role") or ""), + "accountId": str(item.get("account_id") or "") or None, + "status": str(item.get("status") or ""), + } + for item in owner_roster + ], + "selectedCaseId": selected_case_id or None, + "selectedCaseDetail": selected_case_detail, + "selectedCaseAllowedActions": { + "assignToMe": bool(permission_summary.get("can_assign")), + "assignAnyOwner": bool(permission_summary.get("can_assign")), + "statusTransitions": list(workflow_summary.get("transition_options") or []), + "canAddEvidence": bool(permission_summary.get("can_add_evidence")), + "canReleaseRestriction": bool(permission_summary.get("can_release_restriction")), + "canEditRestriction": bool(permission_summary.get("can_edit_restriction")), + }, + "selectedCaseRestriction": restriction, + "selectedCaseTargetValidation": dict((selected_case_detail or {}).get("target_validation") or {}) or None, + "selectedCaseOperatorAudit": list((selected_case_detail or {}).get("operator_audit_trail") or []), + "restrictionHistory": list((selected_case_detail or {}).get("restriction_history") or []), + } + + +def _quantum_ops_governance_queue_workspace_payload( + request: Request, + *, + status: Optional[str], + owner_id: Optional[str], + case_type: Optional[str], + severity: Optional[str], + target_type: Optional[str], + has_active_restriction: Optional[bool], + overdue_only: bool, + unassigned_only: bool, + search: Optional[str], + selected_case_ids: Optional[List[str]], + limit: int, + bulk_preview_result: Optional[Dict[str, Any]] = None, + bulk_execution_result: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + viewer_identity = _maybe_identity(request) or {} + payload = request.app.state.governance_service.owner_workload( + status=status, + owner_id=owner_id, + case_type=case_type, + severity=severity, + target_type=target_type, + has_active_restriction=has_active_restriction, + overdue_only=overdue_only, + unassigned_only=unassigned_only, + search=search, + selected_case_ids=selected_case_ids, + limit=limit, + ) + return { + **payload, + "capacityAdminSurface": { + **dict(payload.get("capacityAdminSurface") or {}), + "canEdit": str(viewer_identity.get("actor_role") or "").strip() == "admin", + }, + "bulkPreviewResult": bulk_preview_result, + "bulkExecutionResult": bulk_execution_result, + } + + +def _quantum_ops_learned_workspace_payload( + request: Request, + *, + world_id: Optional[str], + issue_code: Optional[str], + limit: int, +) -> Dict[str, Any]: + selected_world_id = str(world_id or "").strip() or "jade_court_exam" + selected_issue_code = str(issue_code or "").strip() or "Q03" + world_detail = { + "world_id": selected_world_id, + "status": "deferred", + "surface": "quantum_ops_light", + } + issue_detail = { + "issue_code": selected_issue_code, + "status": "deferred", + "surface": "quantum_ops_light", + } + dashboard = { + "schema_version": "quantum_ops_learned_workspace_light/v1", + "mode": "deferred", + "world_details": [world_detail], + "issue_details": [issue_detail], + "artifact_status": { + "evaluator": {"status": "deferred"}, + "reranker": {"status": "deferred"}, + }, + "recommended_next_focus": selected_issue_code, + } + compare = { + "schema_version": "quantum_ops_learned_compare_light/v1", + "mode": "deferred", + "preferred_shadow_candidate": "deferred", + "recommended_next_action": "open_dedicated_learned_ops", + "disagreement_worlds": [], + "disagreement_issue_codes": [selected_issue_code], + } + rollout = { + "schema_version": "quantum_ops_learned_rollout_light/v1", + "mode": "deferred", + "world_id": selected_world_id, + "limit": max(1, min(100, int(limit or 20))), + "active_rollouts": [], + "candidate_count": 0, + } + return { + "worldId": selected_world_id or None, + "issueCode": selected_issue_code or None, + "dashboard": dashboard, + "compare": compare, + "rollout": rollout, + "selectedWorldDetail": world_detail, + "selectedIssueDetail": issue_detail, + } + + +def _quantum_governance_restriction_catalog() -> List[Dict[str, Any]]: + labels = { + "reader_access_block": "Reader Access Block", + "author_access_block": "Author Access Block", + "checkout_block": "Checkout Block", + "account_hold": "Account Hold", + } + scopes = { + "reader_access_block": "reader", + "author_access_block": "author", + "checkout_block": "checkout", + "account_hold": "account", + } + return [ + {"id": item, "label": labels[item], "scope": scopes[item]} + for item in sorted(["reader_access_block", "author_access_block", "checkout_block", "account_hold"]) + ] + + +def _quantum_governance_case_type_catalog() -> List[Dict[str, Any]]: + labels = { + "rights": "Rights", + "moderation": "Moderation", + "abuse": "Abuse", + } + return [{"id": item, "label": labels[item]} for item in ["rights", "moderation", "abuse"]] + + +def _quantum_governance_target_type_catalog() -> List[Dict[str, Any]]: + labels = { + "account": "Account", + "world_version": "World Version", + "session": "Session", + "entitlement": "Entitlement", + } + return [{"id": item, "label": labels[item]} for item in ["account", "world_version", "session", "entitlement"]] + + +def _quantum_governance_policy_label_suggestions() -> Dict[str, List[str]]: + return { + "rights": ["billing_rights", "entitlement_review", "customer_remedy", "account_scope_review"], + "moderation": ["content_policy", "publication_review", "world_version_moderation", "appeal_review"], + "abuse": ["abuse_signal", "restriction_review", "account_integrity", "enforcement_followup"], + } + + +def _quantum_governance_target_resolver(request: Request, *, account_id: Optional[str], limit: int = 10) -> Dict[str, Any]: + resolver = request.app.state.governance_service.target_resolver(account_id=account_id, limit=limit) + + def _map_item(item: Dict[str, Any]) -> Dict[str, Any]: + return { + "id": str(item.get("id") or ""), + "label": str(item.get("label") or item.get("id") or ""), + "status": str(item.get("status") or ""), + "accountId": str(item.get("account_id") or "") or None, + "targetType": str(item.get("target_type") or ""), + "worldId": str(item.get("world_id") or "") or None, + "worldVersionId": str(item.get("world_version_id") or "") or None, + "sessionId": str(item.get("session_id") or "") or None, + "entitlementId": str(item.get("entitlement_id") or "") or None, + "entitlementType": str(item.get("entitlement_type") or "") or None, + "walletType": str(item.get("wallet_type") or "") or None, + "tierId": str(item.get("tier_id") or "") or None, + "riskRating": str(item.get("risk_rating") or "") or None, + "expiresAt": str(item.get("expires_at") or "") or None, + "validationWarnings": [str(value) for value in list(item.get("validation_warnings") or [])], + } + + return { + "accounts": [_map_item(item) for item in list(resolver.get("accounts") or [])], + "worldVersions": [_map_item(item) for item in list(resolver.get("world_versions") or [])], + "sessions": [_map_item(item) for item in list(resolver.get("sessions") or [])], + "entitlements": [_map_item(item) for item in list(resolver.get("entitlements") or [])], + } + + +def _quantum_governance_target_resolver_meta(request: Request, *, account_id: Optional[str]) -> Dict[str, Any]: + meta = request.app.state.governance_service.target_resolver_meta(account_id=account_id) + return { + "scopeAccountId": str(meta.get("scope_account_id") or "") or None, + "strictScopeEnabled": bool(meta.get("strict_scope_enabled")), + "validationMode": str(meta.get("validation_mode") or ""), + "supportedTargetTypes": [str(item) for item in list(meta.get("supported_target_types") or [])], + } + + +def _quantum_ops_primary_subscription_id(account_detail: Dict[str, Any]) -> Optional[str]: + subscription = dict(account_detail.get("subscription") or {}) + subscription_id = str(subscription.get("subscription_id") or "").strip() + return subscription_id or None + + +def _quantum_ops_bootstrap_payload(request: Request, *, identity: Dict[str, Any]) -> Dict[str, Any]: + # Bootstrap is fetched on every Ops shell boot. Keep it to defaults and + # identity only; full reviewer queues/details and release defaults are + # loaded by dedicated workspace endpoints after the shell is mounted. + default_account_id = _quantum_identity_account_id(identity) or None + default_world_id = "jade_court_exam" + return { + "availableTabs": [ + {"id": "reviewer", "label": "审阅台"}, + {"id": "runtime", "label": "运行观测"}, + {"id": "account", "label": "账户工作区"}, + {"id": "release", "label": "发布工作台"}, + {"id": "alerts", "label": "告警中心"}, + {"id": "learned", "label": "学习层"}, + {"id": "governance", "label": "治理工作台"}, + ], + "defaultTab": "reviewer", + "defaultAccountId": default_account_id, + "defaultWorldId": default_world_id, + "defaultReviewItemId": None, + "reviewer": { + "actorId": str(identity.get("actor_id") or "").strip(), + "actorRole": str(identity.get("actor_role") or "").strip(), + "displayName": str(identity.get("display_name") or identity.get("actor_id") or "").strip(), + "accountId": _quantum_identity_account_id(identity) or None, + }, + "reviewHubSummary": {}, + } + + +def _resolve_plan_tier_id(raw_plan_id: str) -> str: + normalized = str(raw_plan_id or "").strip() + resolved = QUANTUM_PLAN_ALIAS_TO_TIER.get(normalized) + if not resolved: + raise ValueError("unknown_membership_plan") + return resolved + + +def _plan_features_for_tier(request: Request, tier_id: str) -> List[str]: + tier = request.app.state.billing_service.monetization.get_tier(tier_id) + features = [ + f"${float(tier.get('price_usd_monthly') or 0):.0f} / month", + f"{int(float(tier.get('monthly_story_credits') or 0))} story credits / month", + ] + studio_credits = int(float(tier.get("monthly_studio_credits") or 0)) + if studio_credits > 0: + features.append(f"{studio_credits} studio credits / month") + author_access = str(tier.get("author_access") or "none") + if author_access != "none": + features.append(f"Author access: {author_access}") + description = str(tier.get("description") or "").strip() + if description: + features.append(description) + return features + + +def _membership_plan_catalog(request: Request) -> List[Dict[str, Any]]: + identity = _maybe_identity(request) + current_tier = None + expires_at = None + if identity is not None: + account_id = _quantum_identity_account_id(identity) + billing_snapshot = request.app.state.billing_service.subscription_status(account_id=account_id) + subscription = dict(billing_snapshot.get("subscription") or {}) + current_tier = str( + billing_snapshot.get("effective_tier") + or subscription.get("tier_id") + or "" + ).strip() or None + expires_at = subscription.get("period_end") + plans: List[Dict[str, Any]] = [] + for tier_id in ("play_pass", "creator_pass", "studio_pass"): + catalog_entry = QUANTUM_PLAN_CATALOG[tier_id] + tier = request.app.state.billing_service.monetization.get_tier(tier_id) + plans.append( + { + "id": catalog_entry["plan_id"], + "name": catalog_entry["name"], + "price": float(tier.get("price_usd_monthly") or 0.0), + "period": "monthly", + "features": _plan_features_for_tier(request, tier_id), + "isCurrent": current_tier == tier_id, + "expiresAt": expires_at if current_tier == tier_id else None, + } + ) + return plans + + +def _resolve_ink_package(request: Request, raw_package_id: str) -> Dict[str, Any]: + normalized = str(raw_package_id or "").strip() + try: + package = request.app.state.billing_service.monetization.get_ink_package(normalized) + except KeyError as exc: + raise ValueError("unknown_ink_package") from exc + return { + "id": str(package.get("package_id") or normalized), + "amount": int(float(package.get("amount") or 0.0)), + "bonus": int(float(package.get("bonus") or 0.0)), + "price": float(package.get("price_usd") or 0.0), + "isRecommended": bool(package.get("recommended")), + } + + +def _ink_packages_catalog(request: Request) -> List[Dict[str, Any]]: + packages = [] + for package in request.app.state.billing_service.monetization.ink_packages(): + packages.append( + { + "id": str(package.get("package_id") or ""), + "amount": int(float(package.get("amount") or 0.0)), + "bonus": int(float(package.get("bonus") or 0.0)), + "price": float(package.get("price_usd") or 0.0), + "isRecommended": bool(package.get("recommended")), + } + ) + return packages + + +def _request_frontend_origin(request: Request) -> str: + origin = str(request.headers.get("origin") or "").strip() + if origin: + return origin.rstrip("/") + referer = str(request.headers.get("referer") or "").strip() + if referer: + try: + from urllib.parse import urlparse + parsed = urlparse(referer) + if parsed.scheme and parsed.netloc: + return f"{parsed.scheme}://{parsed.netloc}" + except Exception: + pass + return str(request.base_url).rstrip("/") + + +def _checkout_payload( + *, + url: str, + provider: str, + checkout_kind: str, + extra: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + return { + "url": url, + "provider": provider, + "checkoutKind": checkout_kind, + **dict(extra or {}), + } + + +def _start_membership_checkout( + request: Request, + *, + identity: Dict[str, Any], + raw_plan_id: str, +) -> Dict[str, Any]: + account_id = _quantum_identity_account_id(identity) + tier_id = _resolve_plan_tier_id(raw_plan_id) + checkout = request.app.state.billing_service.start_checkout( + account_id=account_id, + tier_id=tier_id, + customer_email=(account_id if "@" in account_id else None), + metadata={ + "compat_surface": "quantum_settings_membership", + "quantum_plan_id": raw_plan_id, + "quantum_frontend_tier": QUANTUM_TIER_MAP.get(tier_id), + }, + ) + return _checkout_payload( + url=str(checkout.get("checkout_url") or ""), + provider=str(checkout.get("provider") or "web_stub"), + checkout_kind="subscription", + extra={ + "checkoutSessionId": checkout.get("checkout_session_id"), + "tierId": tier_id, + "planId": raw_plan_id, + }, + ) + + +def _start_ink_checkout( + request: Request, + *, + identity: Dict[str, Any], + raw_package_id: str, +) -> Dict[str, Any]: + account_id = _quantum_identity_account_id(identity) + package = _resolve_ink_package(request, raw_package_id) + frontend_origin = _request_frontend_origin(request) + success_url = ( + f"{frontend_origin}/settings" + f"?tab=matter&checkout=success&checkout_kind=ink&checkout_session_id={{CHECKOUT_SESSION_ID}}" + ) + cancel_url = f"{frontend_origin}/settings?tab=matter&checkout=cancel&checkout_kind=ink" + checkout = request.app.state.billing_service.start_ink_checkout( + account_id=account_id, + package_id=package["id"], + customer_email=(account_id if "@" in account_id else None), + metadata={ + "compat_surface": "quantum_settings_ink", + "package_id": package["id"], + }, + success_url=success_url, + cancel_url=cancel_url, + ) + request.app.state.analytics_service.track( + "checkout_started", + reader_id=account_id, + account_id=account_id, + access_tier="story_credits", + payload_json={ + "provider": checkout.get("provider"), + "package_id": package["id"], + "amount": package["amount"], + "bonus": package["bonus"], + "price": package["price"], + "checkout_url": checkout.get("checkout_url"), + "checkout_session_id": checkout.get("checkout_session_id"), + }, + ) + return _checkout_payload( + url=str(checkout.get("checkout_url") or ""), + provider=str(checkout.get("provider") or "web_stub"), + checkout_kind="ink", + extra={ + "checkoutSessionId": checkout.get("checkout_session_id"), + "packageId": package["id"], + "amount": package["amount"], + "bonus": package["bonus"], + "price": package["price"], + }, + ) + + +def _studio_title(worldpack: Dict[str, Any], project_id: str) -> str: + return str(worldpack.get("title") or project_id) + + +def _studio_engine(worldpack: Dict[str, Any]) -> str: + quantum_frontend = _studio_quantum_frontend_metadata(worldpack) + engine = str(quantum_frontend.get("engine") or "").strip() + return engine if engine in QUANTUM_STUDIO_ENGINES else "balanced" + + +def _studio_quantum_frontend_metadata(worldpack: Dict[str, Any]) -> Dict[str, Any]: + metadata = dict(worldpack.get("metadata") or {}) + return dict(metadata.get("quantum_frontend") or {}) + + +def _studio_world_rule_specs(worldpack: Dict[str, Any]) -> List[Dict[str, Any]]: + world_bible = dict(worldpack.get("world_bible") or {}) + characters = list(worldpack.get("characters") or []) + arc_plans = list(worldpack.get("arc_plans") or []) + locations = list(world_bible.get("locations") or []) + return [ + {"id": "rule_premise", "name": "核心设定", "default_enabled": bool(str(world_bible.get("premise") or "").strip())}, + {"id": "rule_characters", "name": "角色阵列", "default_enabled": bool(characters)}, + {"id": "rule_arc_plan", "name": "章节弧线", "default_enabled": bool(arc_plans)}, + {"id": "rule_locations", "name": "地点锚定", "default_enabled": bool(locations)}, + ] + + +def _studio_characters(worldpack: Dict[str, Any]) -> List[Dict[str, Any]]: + characters = [] + for index, item in enumerate(list(worldpack.get("characters") or []), start=1): + payload = dict(item or {}) + character_id = str( + payload.get("character_id") + or payload.get("id") + or payload.get("display_name") + or payload.get("name") + or f"character_{index}" + ).strip() + display_name = str( + payload.get("display_name") + or payload.get("name") + or payload.get("character_id") + or f"角色 {index}" + ).strip() + characters.append({"id": character_id, "name": display_name, "avatar": ""}) + return characters + + +def _studio_world_rules(worldpack: Dict[str, Any]) -> List[Dict[str, Any]]: + quantum_frontend = _studio_quantum_frontend_metadata(worldpack) + enabled_rule_ids = quantum_frontend.get("enabled_rule_ids") + if isinstance(enabled_rule_ids, list): + enabled_set = {str(item).strip() for item in enabled_rule_ids if str(item).strip()} + return [ + {"id": item["id"], "name": item["name"], "enabled": item["id"] in enabled_set} + for item in _studio_world_rule_specs(worldpack) + ] + return [ + {"id": item["id"], "name": item["name"], "enabled": bool(item["default_enabled"])} + for item in _studio_world_rule_specs(worldpack) + ] + + +def _studio_arc_nodes(worldpack: Dict[str, Any], *, project_id: str) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + world_bible = dict(worldpack.get("world_bible") or {}) + volume_order_map = { + str(item.get("volume_id") or ""): int(item.get("order") or 0) + for item in list(worldpack.get("volume_plans") or []) + if str(item.get("volume_id") or "").strip() + } + arc_plans = sorted( + [dict(item or {}) for item in list(worldpack.get("arc_plans") or [])], + key=lambda item: ( + volume_order_map.get(str(item.get("volume_id") or ""), 10_000), + int(item.get("order") or 0), + str(item.get("arc_id") or ""), + ), + )[:12] + + nodes: List[Dict[str, Any]] = [ + { + "id": project_id, + "title": _studio_title(worldpack, project_id), + "type": "root", + "x": 380, + "y": 72, + "description": str(world_bible.get("premise") or ""), + "status": "active", + } + ] + connections: List[Dict[str, Any]] = [] + + for index, arc in enumerate(arc_plans): + arc_id = str(arc.get("arc_id") or f"arc_{index + 1}").strip() + first_task = dict((list(arc.get("chapter_tasks") or [{}]) or [{}])[0] or {}) + description = str( + first_task.get("objective") + or first_task.get("notes") + or arc.get("title") + or arc.get("completion_conditions") + or "" + ).strip() + nodes.append( + { + "id": arc_id, + "title": str(arc.get("title") or f"章节弧线 {index + 1}").strip(), + "type": "branch", + "x": 120 + (index % 3) * 260, + "y": 260 + (index // 3) * 210, + "description": description, + "status": "active", + } + ) + connections.append( + { + "from": project_id, + "to": arc_id, + "label": str(arc.get("volume_id") or ""), + } + ) + return nodes, connections + + +def _studio_project_graph(worldpack: Dict[str, Any], *, project_id: str) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + quantum_frontend = _studio_quantum_frontend_metadata(worldpack) + stored_nodes = list(quantum_frontend.get("nodes") or []) + stored_connections = list(quantum_frontend.get("connections") or []) + if stored_nodes: + nodes: List[Dict[str, Any]] = [] + for index, item in enumerate(stored_nodes): + payload = dict(item or {}) + node_id = str(payload.get("id") or f"node_{index + 1}").strip() + if not node_id: + continue + nodes.append( + { + "id": node_id, + "title": str(payload.get("title") or node_id), + "type": str(payload.get("type") or "branch"), + "x": int(payload.get("x") or 0), + "y": int(payload.get("y") or 0), + "description": str(payload.get("description") or ""), + "status": str(payload.get("status") or "active"), + } + ) + connections = [ + { + "from": str(dict(item or {}).get("from") or ""), + "to": str(dict(item or {}).get("to") or ""), + "label": str(dict(item or {}).get("label") or ""), + } + for item in stored_connections + if str(dict(item or {}).get("from") or "").strip() and str(dict(item or {}).get("to") or "").strip() + ] + if nodes: + return nodes, connections + return _studio_arc_nodes(worldpack, project_id=project_id) + + +def _studio_update_quantum_frontend( + worldpack: Dict[str, Any], + *, + engine: Optional[str] = None, + enabled_rule_ids: Optional[List[str]] = None, + nodes: Optional[List[Dict[str, Any]]] = None, + connections: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + next_worldpack = copy.deepcopy(worldpack) + metadata = dict(next_worldpack.get("metadata") or {}) + quantum_frontend = dict(metadata.get("quantum_frontend") or {}) + if engine is not None: + quantum_frontend["engine"] = engine + if enabled_rule_ids is not None: + quantum_frontend["enabled_rule_ids"] = list(enabled_rule_ids) + if nodes is not None: + quantum_frontend["nodes"] = list(nodes) + if connections is not None: + quantum_frontend["connections"] = list(connections) + metadata["quantum_frontend"] = quantum_frontend + next_worldpack["metadata"] = metadata + return next_worldpack + + +def _studio_preview_payload(project_id: str, simulation_report: Dict[str, Any]) -> Dict[str, Any]: + chapter_evaluations = list(simulation_report.get("chapter_evaluations") or []) + issue_counts: Dict[str, int] = {} + for item in chapter_evaluations: + for issue in list(item.get("issues") or []): + code = str(dict(issue or {}).get("issue_code") or "").strip() + if not code: + continue + issue_counts[code] = issue_counts.get(code, 0) + 1 + top_issue_codes = [ + code + for code, _count in sorted(issue_counts.items(), key=lambda item: (-item[1], item[0]))[:5] + ] + summary = ( + f"模拟 {len(chapter_evaluations)} 章" + + (f" · 主要问题 {', '.join(top_issue_codes)}" if top_issue_codes else " · 当前未发现显著问题") + ) + return { + "previewId": f"preview_{project_id}_{uuid4().hex[:8]}", + "status": "completed", + "chapterCount": len(chapter_evaluations), + "issueCodes": top_issue_codes, + "summary": summary, + } + + +def _studio_export_payload(project: Dict[str, Any], *, format_value: str) -> str: + normalized = str(format_value or "").strip().lower() + if normalized == "json": + return json.dumps(project, ensure_ascii=False, indent=2) + lines = [ + f"# {project['title']}", + "", + f"- projectId: {project['id']}", + f"- engine: {project['engine']}", + f"- worldRules: {', '.join(item['name'] for item in project.get('worldRules', []) if item.get('enabled')) or '-'}", + "", + "## Characters", + *(f"- {item['name']}" for item in project.get("characters", [])), + "", + "## Nodes", + *(f"- {item['id']}: {item['title']} ({item['type']})" for item in project.get("nodes", [])), + "", + "## Connections", + *(f"- {item['from']} -> {item['to']}" + (f" [{item['label']}]" if item.get("label") else "") for item in project.get("connections", [])), + ] + return "\n".join(lines).strip() + "\n" + + +def _studio_project_payload(project_id: str, draft_detail: Dict[str, Any]) -> Dict[str, Any]: + worldpack = dict(draft_detail.get("worldpack") or {}) + nodes, connections = _studio_project_graph(worldpack, project_id=project_id) + return { + "id": project_id, + "title": _studio_title(worldpack, project_id), + "engine": _studio_engine(worldpack), + "availableEngines": list(QUANTUM_STUDIO_ENGINES), + "worldRules": _studio_world_rules(worldpack), + "characters": _studio_characters(worldpack), + "nodes": nodes, + "connections": connections, + } + + +def _ensure_studio_project_owner(identity: Dict[str, Any], draft_detail: Dict[str, Any]) -> None: + worldpack = dict(draft_detail.get("worldpack") or {}) + manifest = dict(worldpack.get("manifest") or {}) + author_id = str(manifest.get("author_id") or "").strip() + allowed_ids = { + str(identity.get("account_id") or "").strip(), + str(identity.get("actor_id") or "").strip(), + } + allowed_ids.discard("") + if author_id and author_id not in allowed_ids: + raise PermissionError("studio_project_forbidden") + + +def _frontend_user(request: Request, identity: Dict[str, Any]) -> Dict[str, Any]: + actor_id = str(identity.get("actor_id") or "").strip() + account_id = str(identity.get("account_id") or actor_id).strip() + try: + account_security = request.app.state.repository.get_auth_identity_profile(actor_id, default={}) + except Exception: + account_security = {} + billing_snapshot = request.app.state.billing_service.subscription_status(account_id=account_id) + entitlements = request.app.state.billing_service.list_entitlements_for_account(account_id) + wallets = dict(entitlements.get("wallets") or {}) + story_wallet = dict(wallets.get("story_credits") or {}) + subscription = dict(billing_snapshot.get("subscription") or {}) + effective_tier = str(billing_snapshot.get("effective_tier") or subscription.get("tier_id") or "").strip() + created_at = identity.get("created_at") + if not created_at: + auth_identity = request.app.state.repository.get_auth_identity(actor_id) + created_at = auth_identity.get("created_at") + email_value = ( + account_security.get("email_address") + or (account_id if "@" in account_id else "") + or (actor_id if "@" in actor_id else "") + ) + return { + "id": account_id or actor_id, + "username": actor_id, + "displayName": identity.get("display_name") or actor_id, + "avatar": str(account_security.get("avatar_url") or ""), + "email": email_value, + "actorRole": str(identity.get("actor_role") or "").strip(), + "inkBalance": float(story_wallet.get("balance") or 0.0), + "membershipTier": _frontend_membership_tier(effective_tier), + "membershipExpiresAt": subscription.get("period_end"), + "createdAt": created_at or "", + } + + +def _default_soul_dimensions() -> List[Dict[str, Any]]: + return [ + {"label": "理性", "value": 0, "max": 100}, + {"label": "情感", "value": 0, "max": 100}, + {"label": "冒险", "value": 0, "max": 100}, + {"label": "命运", "value": 0, "max": 100}, + {"label": "混沌", "value": 0, "max": 100}, + ] + + +def _parse_iso_timestamp(value: Any) -> Optional[datetime]: + normalized = str(value or "").strip() + if not normalized: + return None + try: + parsed = datetime.fromisoformat(normalized.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _reader_recent_activity_map(request: Request, *, account_id: str) -> Dict[str, Dict[str, Any]]: + events = request.app.state.repository.list_analytics_events( + event_names=["session_created", "continue_story"], + reader_id=account_id, + limit=200, + ) + latest_by_session: Dict[str, Dict[str, Any]] = {} + for event in events: + session_id = str(event.get("session_id") or "").strip() + if not session_id: + continue + occurred_at = _parse_iso_timestamp(event.get("occurred_at")) + current = latest_by_session.get(session_id) + current_dt = current.get("occurred_at_dt") if current else None + if current is None or (occurred_at is not None and (current_dt is None or occurred_at > current_dt)): + latest_by_session[session_id] = { + "event_name": str(event.get("event_name") or ""), + "occurred_at": event.get("occurred_at"), + "occurred_at_dt": occurred_at, + } + return latest_by_session + + +def _soul_recent_reader_sessions(request: Request, *, account_id: str) -> List[Dict[str, Any]]: + activity_by_session = _reader_recent_activity_map(request, account_id=account_id) + bookmark_summary_by_session = _story_bookmark_summary_by_session(request, account_id=account_id) + recent_items: List[Dict[str, Any]] = [] + for session in request.app.state.repository.list_sessions(): + session_id = str(session.get("session_id") or "").strip() + if not session_id: + continue + try: + detail = request.app.state.repository.get_session(session_id) + except KeyError: + continue + if str(detail.metadata.get("reader_id") or "").strip() != account_id: + continue + try: + version = request.app.state.repository.get_world_version(str(session.get("world_version_id") or "")) + world_title = str((version.worldpack_json or {}).get("title") or version.world_id) + except KeyError: + world_title = str(session.get("world_id") or session_id) + activity = activity_by_session.get(session_id, {}) + updated_at = str(activity.get("occurred_at") or session.get("created_at") or "") + bookmark_summary = dict(bookmark_summary_by_session.get(session_id) or {}) + current_node_id = _story_projected_current_node_id(request, session_id=session_id) + recent_items.append( + { + "id": session_id, + "title": str(session.get("last_chapter_title") or world_title), + "coverImage": _session_cover_image( + request, + session_id=session_id, + world_version_id=str(session.get("world_version_id") or ""), + ), + "branchName": str(session.get("last_event_title") or "阅读进度"), + "progress": int(session.get("current_turn_index") or 0), + "kind": "reader_session", + "targetHref": f"/story?session={quote(session_id, safe='')}", + "updatedAt": updated_at, + "currentNodeId": current_node_id, + "viewerHasBookmarkedCurrentNode": current_node_id in set(bookmark_summary.get("node_ids") or set()), + "_updated_at_dt": _parse_iso_timestamp(updated_at), + } + ) + recent_items.sort( + key=lambda item: item.get("_updated_at_dt") or datetime.fromtimestamp(0, tz=timezone.utc), + reverse=True, + ) + return recent_items + + +def _soul_recent_author_drafts(request: Request, *, account_id: str) -> List[Dict[str, Any]]: + recent_items: List[Dict[str, Any]] = [] + for item in request.app.state.repository.list_world_versions(): + world_version_id = str(item.get("world_version_id") or "").strip() + if not world_version_id: + continue + try: + version = request.app.state.repository.get_world_version(world_version_id) + except KeyError: + continue + if str(version.author_id or "").strip() != account_id: + continue + updated_at = str(item.get("updated_at") or "") + recent_items.append( + { + "id": f"draft:{world_version_id}", + "title": str((version.worldpack_json or {}).get("title") or version.world_id), + "coverImage": _world_cover_image(request, world_version_id=world_version_id), + "branchName": "创作草稿", + "progress": 0, + "kind": "author_draft", + "targetHref": f"/studio/{quote(world_version_id, safe='')}", + "updatedAt": updated_at, + "_updated_at_dt": _parse_iso_timestamp(updated_at), + } + ) + recent_items.sort( + key=lambda item: item.get("_updated_at_dt") or datetime.fromtimestamp(0, tz=timezone.utc), + reverse=True, + ) + return recent_items + + +def _soul_recent_activity_feed(request: Request, *, account_id: str) -> List[Dict[str, Any]]: + merged = _soul_recent_reader_sessions(request, account_id=account_id) + _soul_recent_author_drafts(request, account_id=account_id) + merged.sort( + key=lambda item: item.get("_updated_at_dt") or datetime.fromtimestamp(0, tz=timezone.utc), + reverse=True, + ) + output = [] + for item in merged[:6]: + payload = dict(item) + payload.pop("_updated_at_dt", None) + output.append(payload) + return output + + +def _soul_recent_activity_count(request: Request, *, account_id: str) -> int: + activity_window_start = datetime.now(timezone.utc).timestamp() - (24 * 60 * 60) + events = request.app.state.repository.list_analytics_events( + event_names=[ + "session_created", + "continue_story", + "author_draft_created_from_brief", + "author_draft_saved", + "author_draft_updated", + "author_draft_validated", + "author_draft_simulated", + "author_draft_submitted", + "author_longform_workbench_bootstrapped", + ], + reader_id=account_id, + limit=500, + ) + count = 0 + for event in events: + occurred_at = _parse_iso_timestamp(event.get("occurred_at")) + if occurred_at and occurred_at.timestamp() >= activity_window_start: + count += 1 + return count + + +def _normalize_library_filter(raw_filter: Any) -> str: + normalized = str(raw_filter or "").strip() + return normalized if normalized in QUANTUM_LIBRARY_FILTERS else "recent" + + +def _normalize_showcase_sort(raw_sort: Any) -> str: + normalized = str(raw_sort or "").strip() + return normalized if normalized in QUANTUM_SHOWCASE_SORTS else "hot" + + +def _library_works_payload(request: Request, *, account_id: Optional[str], filter_value: str) -> List[Dict[str, Any]]: + return request.app.state.quantum_read_model_service.library_works( + account_id=account_id, + filter_value=filter_value, + ) + + +def _library_stats_payload(request: Request, *, account_id: Optional[str]) -> Dict[str, Any]: + return request.app.state.library_stats_cube_service.get_stats(account_id=account_id) + + +def _story_import_public_works_payload(request: Request) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + for world_card in request.app.state.repository.list_worlds(): + world_id = str(world_card.get("world_id") or "").strip() + if not world_id: + continue + if str(world_card.get("catalog_role") or "").strip() == "template": + continue + if world_card.get("public_catalog_visible") is False: + continue + published_summary = next( + ( + item + for item in request.app.state.repository.list_world_versions(world_id=world_id) + if str(item.get("status") or "").strip() == "published" + ), + None, + ) + if published_summary is None: + continue + try: + version = request.app.state.repository.get_world_version(str(published_summary.get("world_version_id") or "")) + except KeyError: + continue + manifest = dict(version.manifest_json or {}) + worldpack = dict(version.worldpack_json or {}) + metadata = dict(worldpack.get("metadata") or {}) + if not _is_public_catalog_visible(metadata): + continue + world_bible = dict(worldpack.get("world_bible") or {}) + genres = list(manifest.get("genres") or []) + readiness = dict(metadata.get("longform_500_product_readiness") or {}) + items.append( + { + "id": world_id, + "worldId": world_id, + "worldVersionId": version.world_version_id, + "title": str(worldpack.get("title") or world_card.get("title") or world_id), + "authorName": str(manifest.get("author_id") or "官方"), + "genre": str(genres[0] if genres else "未分类"), + "coverImage": _world_cover_image(request, world_version_id=version.world_version_id), + "description": str(world_bible.get("premise") or ""), + "status": "available", + "trialAvailable": bool(world_card.get("trial_available")), + "accessState": str(world_card.get("access_state") or "trial"), + "claimSafeBand": metadata.get("claim_safe_band") or world_card.get("claim_safe_band"), + "productReadyBand": metadata.get("product_ready_band") or world_card.get("product_ready_band"), + "longform500ProductReady": bool(readiness.get("ready", False)), + } + ) + return items + + +def _story_import_handoff_url( + request: Request, + *, + session_id: str, + world_id: str, + account_id: Optional[str] = None, +) -> str: + params = [ + ("product", "reader"), + ("workspace", "read"), + ("view", "experience"), + ("session_id", session_id), + ("world_id", world_id), + ] + if account_id: + params.append(("account_id", account_id)) + query = "&".join(f"{quote_plus(key)}={quote_plus(value)}" for key, value in params) + return f"{str(request.base_url).rstrip('/')}/app?{query}" + + +def _story_import_recent_payload(request: Request, *, account_id: Optional[str]) -> List[Dict[str, Any]]: + if not account_id: + return [] + activity_by_session = _reader_recent_activity_map(request, account_id=account_id) + items: List[Dict[str, Any]] = [] + for session in request.app.state.repository.list_sessions(): + session_id = str(session.get("session_id") or "").strip() + if not session_id: + continue + try: + detail = request.app.state.repository.get_session(session_id) + except KeyError: + continue + owner_account_id = str(detail.metadata.get("reader_id") or detail.player_profile.get("reader_id") or "").strip() + if owner_account_id != account_id: + continue + world_id = str(session.get("world_id") or detail.world_id or "").strip() + world_title = world_id or session_id + try: + version = request.app.state.repository.get_world_version(str(detail.metadata.get("world_version_id") or "")) + except KeyError: + version = None + if version is not None: + world_title = str((version.worldpack_json or {}).get("title") or version.world_id or world_title) + updated_at = str(activity_by_session.get(session_id, {}).get("occurred_at") or session.get("created_at") or "") + progress = int(session.get("current_turn_index") or 0) + items.append( + { + "id": session_id, + "sessionId": session_id, + "worldId": world_id, + "worldVersionId": str(detail.metadata.get("world_version_id") or ""), + "title": str(session.get("last_chapter_title") or world_title), + "subtitle": str(session.get("last_event_title") or "继续上次推演"), + "progress": progress, + "coverImage": _session_cover_image( + request, + session_id=session_id, + world_version_id=str(detail.metadata.get("world_version_id") or ""), + ), + "updatedAt": updated_at, + "handoffUrl": _story_import_handoff_url( + request, + session_id=session_id, + world_id=world_id, + account_id=account_id, + ), + "_updated_at_dt": _parse_iso_timestamp(updated_at), + } + ) + items.sort( + key=lambda item: item.get("_updated_at_dt") or datetime.fromtimestamp(0, tz=timezone.utc), + reverse=True, + ) + output: List[Dict[str, Any]] = [] + for item in items: + payload = dict(item) + payload.pop("_updated_at_dt", None) + output.append(payload) + return output + + +def _story_owner_account_id(session_record: Any) -> Optional[str]: + owner_account_id = str( + (getattr(session_record, "metadata", {}) or {}).get("reader_id") + or (getattr(session_record, "player_profile", {}) or {}).get("reader_id") + or "" + ).strip() + return owner_account_id or None + + +def _story_identity_account_id(identity: Optional[Dict[str, Any]]) -> Optional[str]: + if identity is None: + return None + value = str(identity.get("account_id") or identity.get("actor_id") or "").strip() + return value or None + + +def _story_continue_reader_id(bundle: Dict[str, Any]) -> Optional[str]: + owner_account_id = str(bundle.get("owner_account_id") or "").strip() + viewer_account_id = str(bundle.get("viewer_account_id") or "").strip() + return owner_account_id or viewer_account_id or None + + +def _story_record_guest_session_claim( + request: Request, + *, + bundle: Dict[str, Any], + account_id: str, +) -> None: + session_record = bundle["session_record"] + world_version = bundle["world_version"] + world_version_id = str((session_record.metadata or {}).get("world_version_id") or "") + request.app.state.analytics_service.track( + "guest_story_session_claimed", + reader_id=account_id, + account_id=account_id, + session_id=session_record.session_id, + world_id=getattr(world_version, "world_id", None) or session_record.world_id, + world_version_id=world_version_id, + payload_json={ + "source_surface": "story_choice", + "claim_policy": "auto_claim_on_authenticated_choice", + }, + ) + audit_service = getattr(request.app.state, "commercial_audit_service", None) + if audit_service is None: + return + identity = dict(bundle.get("identity") or {}) + try: + audit_service.record_audit_log( + actor_id=account_id, + actor_role=str(identity.get("actor_role") or "reader"), + account_id=account_id, + object_type="story_session", + object_id=session_record.session_id, + action_type="guest_story_session_claimed", + source_surface="story_choice", + customer_visible_payload={ + "session_id": session_record.session_id, + "world_version_id": world_version_id, + }, + internal_payload={ + "claim_policy": "auto_claim_on_authenticated_choice", + "previous_owner_account_id": None, + }, + ) + except Exception: + return + + +def _story_claim_guest_session_for_viewer(request: Request, *, bundle: Dict[str, Any]) -> Dict[str, Any]: + if str(bundle.get("owner_account_id") or "").strip(): + return bundle + viewer_account_id = str(bundle.get("viewer_account_id") or "").strip() + if not viewer_account_id: + return bundle + session_record = bundle["session_record"] + claim = request.app.state.repository.claim_guest_session( + session_record.session_id, + reader_id=viewer_account_id, + ) + claim_status = str(claim.get("status") or "") + if claim_status == "conflict": + raise PermissionError("story_session_forbidden") + if claim_status == "claimed": + _story_record_guest_session_claim(request, bundle=bundle, account_id=viewer_account_id) + return _story_session_bundle(request, session_id=session_record.session_id) + + +def _story_bookmark_summary_by_session( + request: Request, + *, + account_id: Optional[str], +) -> Dict[str, Dict[str, Any]]: + if not account_id: + return {} + summary: Dict[str, Dict[str, Any]] = {} + for item in request.app.state.repository.list_story_session_bookmarks(account_id=account_id): + session_id = str(item.get("session_id") or "").strip() + if not session_id: + continue + entry = summary.setdefault( + session_id, + { + "node_ids": set(), + "count": 0, + "latest_node_id": None, + "latest_at": "", + "_latest_dt": None, + }, + ) + node_id = str(item.get("node_id") or "").strip() + if node_id: + entry["node_ids"].add(node_id) + entry["count"] += 1 + updated_at = str(item.get("updated_at") or item.get("created_at") or "") + updated_dt = _parse_iso_timestamp(updated_at) + current_dt = entry.get("_latest_dt") + if current_dt is None or (updated_dt is not None and updated_dt > current_dt): + entry["latest_node_id"] = node_id or entry.get("latest_node_id") + entry["latest_at"] = updated_at + entry["_latest_dt"] = updated_dt + return summary + + +def _story_public_share_bundle(request: Request, *, share_token: str) -> Dict[str, Any]: + share_row = request.app.state.repository.get_story_session_share_token(share_token) + session_record = request.app.state.repository.get_session(str(share_row.get("session_id") or "")) + world_version_id = str((session_record.metadata or {}).get("world_version_id") or "") + world_version = request.app.state.repository.get_world_version(world_version_id) + chapter_rows = request.app.state.repository.list_story_chapter_payloads(session_record.session_id) + return { + "request": request, + "share_row": share_row, + "identity": None, + "viewer_account_id": None, + "owner_account_id": _story_owner_account_id(session_record), + "session_record": session_record, + "world_version": world_version, + "chapter_rows": chapter_rows, + "bookmark_summary_by_session": {}, + } + + +def _story_share_token_inactive_reason(share_row: Dict[str, Any]) -> Optional[str]: + if str(share_row.get("status") or "active") == "revoked" or str(share_row.get("revoked_at") or "").strip(): + return "revoked" + expires_at = _parse_iso_timestamp(share_row.get("expires_at")) + if expires_at is not None and expires_at <= datetime.now(timezone.utc): + return "expired" + return None + + +def _story_share_url(share_token: str) -> str: + return f"/story/share/{quote(share_token, safe='')}" + + +def _story_bookmark_response_payload( + request: Request, + *, + session_id: str, + account_id: str, + node_id: str, + saved: bool, + bookmark_id: Optional[str] = None, +) -> Dict[str, Any]: + bookmark_items = request.app.state.repository.list_story_session_bookmarks( + session_id=session_id, + account_id=account_id, + ) + bookmarked_node_ids = { + str(item.get("node_id") or "").strip() + for item in bookmark_items + if str(item.get("node_id") or "").strip() + } + return { + "bookmarkId": bookmark_id, + "sessionId": session_id, + "nodeId": node_id, + "saved": saved, + "bookmarkCount": len(bookmarked_node_ids), + "viewerHasBookmarkedNode": node_id in bookmarked_node_ids, + } + + +def _story_clamp_score(value: Any) -> int: + try: + numeric = int(round(float(value or 0.0))) + except (TypeError, ValueError): + numeric = 0 + return max(0, min(100, numeric)) + + +def _story_get_field(value: Any, key: str, default: Any = None) -> Any: + if isinstance(value, dict): + return value.get(key, default) + return getattr(value, key, default) + + +def _story_total_deviation(state: Any) -> int: + return _story_clamp_score(float(_story_get_field(state, "fate_pressure", 0.0) or 0.0) * 100.0) + + +def _story_breakdown_payload(state: Any) -> Dict[str, int]: + themes = dict(_story_get_field(state, "themes", {}) or {}) + return { + "character": _story_clamp_score(min(1.0, len(list(_story_get_field(state, "open_promises", []) or [])) / 5.0) * 100.0), + "plot": _story_clamp_score(float(_story_get_field(state, "tension", 0.0) or 0.0) * 100.0), + "theme": _story_clamp_score(max([float(value or 0.0) for value in themes.values()], default=0.0) * 100.0), + } + + +def _story_paywall_payload(paywall: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + if not paywall: + return None + payload = dict(paywall) + return { + "required": bool(payload.get("required")), + "reason": str(payload.get("reason") or ""), + "quote": float(payload.get("quote") or 0.0), + "accessTier": str(payload.get("access_tier") or ""), + "balance": float(payload.get("balance") or 0.0) if payload.get("balance") is not None else None, + "entitlementType": payload.get("entitlement_type"), + "status": payload.get("status"), + } + + +def _story_continuity_contract_payload(contract: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + if not contract: + return None + payload = dict(contract) + return { + "status": str(payload.get("status") or ""), + "message": str(payload.get("message") or ""), + "primaryAction": payload.get("primary_action"), + "preserveWorkspace": payload.get("preserve_workspace"), + "preserveSessionContext": bool(payload.get("preserve_session_context")) if payload.get("preserve_session_context") is not None else None, + "chapterContextRetained": bool(payload.get("chapter_context_retained")) if payload.get("chapter_context_retained") is not None else None, + } + + +def _story_quality_gate_payload(gate: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + if not gate: + return None + payload = dict(gate) + return { + "code": str(payload.get("code") or ""), + "summary": str(payload.get("summary") or ""), + "enforcedDecision": payload.get("enforced_decision"), + "failedChecks": list(payload.get("failed_checks") or []), + } + + +def _story_projected_current_node_id(request: Request, *, session_id: str) -> str: + try: + bundle = _story_session_bundle(request, session_id=session_id) + except Exception: + return f"node:{session_id}:0" + nodes = _story_nodes_payload(bundle) + return str(nodes[-1]["id"]) if nodes else f"node:{session_id}:0" + + +def _story_reader_view_scene_card(reader_view: Any) -> Dict[str, Any]: + if isinstance(reader_view, dict): + return dict(reader_view.get("scene_card") or {}) + return dict(getattr(reader_view, "scene_card", {}) or {}) + + +def _story_reader_view_quote(reader_view: Any) -> str: + scene_card = _story_reader_view_scene_card(reader_view) + return str(scene_card.get("quote") or scene_card.get("pull_quote") or "").strip() + + +def _story_reader_view_beats(step: Any, reader_view: Any) -> List[str]: + scene_card = _story_reader_view_scene_card(reader_view) + beats = [ + str(item or "").strip() + for item in list(scene_card.get("story_beats") or scene_card.get("beats") or []) + if str(item or "").strip() + ] + if beats: + return beats + + output: List[str] = [] + for beat in list(_story_get_field(step, "scene_beats", []) or []): + event = beat.get("event") if isinstance(beat, dict) else getattr(beat, "event", None) + title = str(_story_get_field(event, "title", "") or "").strip() + summary = str(_story_get_field(event, "summary", "") or "").strip() + value = title or summary + if value: + output.append(value) + return output + + +def _world_cover_image(request: Request, *, world_version_id: str) -> str: + service = getattr(request.app.state, "illustration_service", None) + if service is None or not world_version_id: + return "" + return service.world_cover_url(world_version_id=world_version_id) + + +def _session_cover_image(request: Request, *, session_id: str, world_version_id: str) -> str: + service = getattr(request.app.state, "illustration_service", None) + if service is None: + return "" + return service.session_cover_url(session_id=session_id) or _world_cover_image( + request, + world_version_id=world_version_id, + ) + + +def _session_atmosphere_image(request: Request, *, session_id: str) -> str: + service = getattr(request.app.state, "illustration_service", None) + if service is None: + return "" + return service.latest_chapter_hero_url(session_id=session_id) + + +def _story_session_bundle( + request: Request, + *, + 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 = request.app.state.repository.get_session(session_id) + owner_account_id = _story_owner_account_id(session_record) + identity = _maybe_identity(request) + viewer_account_id = _story_identity_account_id(identity) + if owner_account_id: + if not viewer_account_id: + raise PermissionError("auth_required") + if viewer_account_id != owner_account_id: + raise PermissionError("story_session_forbidden") + world_version_id = str((session_record.metadata or {}).get("world_version_id") or "") + world_version = request.app.state.repository.get_world_version(world_version_id) + chapter_rows = request.app.state.repository.list_story_chapter_payloads( + session_id, + start_chapter=start_chapter, + end_chapter=end_chapter, + limit=limit, + latest=latest, + ) + total_chapters = request.app.state.repository.count_story_chapters(session_id) + return { + "request": request, + "identity": identity, + "viewer_account_id": viewer_account_id, + "owner_account_id": owner_account_id, + "session_record": session_record, + "world_version": world_version, + "chapter_rows": chapter_rows, + "chapter_projection": { + "schema_version": "story_chapter_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_rows), + "total_chapters": total_chapters, + }, + "bookmark_summary_by_session": _story_bookmark_summary_by_session(request, account_id=viewer_account_id), + } + + +def _story_nodes_payload(bundle: Dict[str, Any], *, truncate_at_node_id: Optional[str] = None) -> List[Dict[str, Any]]: + session_record = bundle["session_record"] + world_version = bundle["world_version"] + story_chapters = list(bundle.get("chapter_rows") or bundle.get("steps") or []) + bookmark_summary = dict((bundle.get("bookmark_summary_by_session") or {}).get(session_record.session_id) or {}) + bookmarked_node_ids = set(bookmark_summary.get("node_ids") or set()) + nodes: List[Dict[str, Any]] = [] + + for index, step in enumerate(story_chapters): + reader_view = _story_get_field(step, "reader_view") + if reader_view is None: + continue + chapter_index = int( + _story_get_field(reader_view, "chapter_index", 0) + or _story_get_field(step, "step_index", 0) + or _story_get_field(step, "chapter_index", 0) + or index + 1 + ) + chapter_title = str(_story_get_field(reader_view, "chapter_title", "") or f"第 {chapter_index} 章") + scene_card = _story_reader_view_scene_card(reader_view) + node_id = f"node:{session_record.session_id}:{chapter_index}" + state_before = _story_get_field(step, "state_before") + state_after = _story_get_field(step, "state_after") + previous_total = _story_total_deviation(state_before) + current_total = _story_total_deviation(state_after) + is_first_story_chapter = chapter_index <= 1 + node_type = "original" if is_first_story_chapter else "ai" + author_name = "原著开篇" if is_first_story_chapter else "AI推演" + tension_delta = max( + float(_story_get_field(state_after, "tension", 0.0) or 0.0) + - float(_story_get_field(state_before, "tension", 0.0) or 0.0), + 0.0, + ) + nodes.append( + { + "id": node_id, + "content": str(_story_get_field(reader_view, "body", "") or ""), + "chapterTitle": chapter_title, + "chapterIndex": chapter_index, + "quote": _story_reader_view_quote(reader_view), + "beats": _story_reader_view_beats(step, reader_view), + "sceneCard": scene_card, + "type": node_type, + "parentId": nodes[-1]["id"] if nodes else (f"node:{session_record.session_id}:{chapter_index - 1}" if chapter_index > 1 else None), + "childrenIds": [], + "branchType": None, + "deviationDelta": current_total - previous_total, + "mentalCost": max( + 1, + int(round(tension_delta * 20.0)), + ), + "createdAt": str(_story_get_field(step, "created_at", "") or ""), + "authorName": author_name, + "authorAvatar": "", + "isCurrent": False, + "isBookmarked": node_id in bookmarked_node_ids, + } + ) + + for index, item in enumerate(nodes): + item["childrenIds"] = [nodes[index + 1]["id"]] if index + 1 < len(nodes) else [] + item["isCurrent"] = index == len(nodes) - 1 + + if truncate_at_node_id: + truncated: List[Dict[str, Any]] = [] + for item in nodes: + truncated.append(item) + if item["id"] == truncate_at_node_id: + break + nodes = truncated + for index, item in enumerate(nodes): + item["childrenIds"] = [nodes[index + 1]["id"]] if index + 1 < len(nodes) else [] + item["isCurrent"] = index == len(nodes) - 1 + + if nodes: + return nodes + + worldpack = dict(getattr(world_version, "worldpack_json", {}) or {}) + world_bible = dict(worldpack.get("world_bible") or {}) + return [ + { + "id": f"node:{session_record.session_id}:0", + "content": str(world_bible.get("premise") or worldpack.get("title") or world_version.world_id), + "type": "original", + "parentId": None, + "childrenIds": [], + "branchType": None, + "deviationDelta": 0, + "mentalCost": 1, + "createdAt": str(getattr(session_record, "created_at", "") or ""), + "authorName": "原著开篇", + "authorAvatar": "", + "isCurrent": True, + "isBookmarked": False, + } + ] + + +def _story_session_payload(bundle: Dict[str, Any], *, truncate_at_node_id: Optional[str] = None) -> Dict[str, Any]: + request = bundle["request"] + session_record = bundle["session_record"] + world_version = bundle["world_version"] + story_chapters = list(bundle.get("chapter_rows") or bundle.get("steps") or []) + nodes = _story_nodes_payload(bundle, truncate_at_node_id=truncate_at_node_id) + projection = dict(bundle.get("chapter_projection") or {}) + node_count = max(len(nodes), int(projection.get("total_chapters", 0) or 0)) + current_state = getattr(session_record, "current_state", None) + if story_chapters: + latest_step = story_chapters[-1] if projection.get("is_windowed") else story_chapters[min(len(story_chapters), max(1, node_count)) - 1] + current_state = _story_get_field(latest_step, "state_after", current_state) + else: + latest_step = None + latest_reader_view = _story_get_field(latest_step, "reader_view") if latest_step else None + chapter_index = int(_story_get_field(current_state, "chapter_index", 0) or 0) + worldpack = dict(getattr(world_version, "worldpack_json", {}) or {}) + bookmark_summary = dict((bundle.get("bookmark_summary_by_session") or {}).get(session_record.session_id) or {}) + current_node_id = str(nodes[-1]["id"]) + latest_chapter_title = str(_story_get_field(latest_reader_view, "chapter_title", "") or f"第 {chapter_index} 章") + return { + "id": session_record.session_id, + "title": str(worldpack.get("title") or world_version.world_id), + "chapter": latest_chapter_title, + "universe": str(world_version.world_id or ""), + "coverImage": _session_cover_image( + request, + session_id=session_record.session_id, + world_version_id=world_version.world_version_id, + ), + "status": "active", + "nodeCount": node_count if node_count else len(nodes), + "currentNodeId": current_node_id, + "nodeProjection": projection, + "atmosphereImage": _session_atmosphere_image(request, session_id=session_record.session_id), + "mentalValue": _story_clamp_score(100 - round(float(getattr(current_state, "tension", 0.0) or 0.0) * 100.0)), + "maxMentalValue": 100, + "viewerHasBookmarkedCurrentNode": current_node_id in set(bookmark_summary.get("node_ids") or set()), + } + + +def _story_choices_payload(bundle: Dict[str, Any], *, node_id: str) -> List[Dict[str, Any]]: + nodes = _story_nodes_payload(bundle) + current_node_id = str(nodes[-1]["id"]) + if node_id != current_node_id: + return [] + story_chapters = list(bundle.get("chapter_rows") or bundle.get("steps") or []) + latest_step = (story_chapters or [None])[-1] + latest_reader_view = _story_get_field(latest_step, "reader_view") if latest_step else None + current_state = getattr(bundle["session_record"], "current_state", None) + if latest_step is not None: + current_state = _story_get_field(latest_step, "state_after", current_state) + current_total = _story_total_deviation(current_state) + if latest_reader_view is None: + return [] + scene_card = dict(getattr(latest_reader_view, "scene_card", {}) or {}) + if isinstance(latest_reader_view, dict): + scene_card = dict(latest_reader_view.get("scene_card") or {}) + raw_choices = list(_story_get_field(latest_reader_view, "choices", []) or []) + raw_impacts = list(_story_get_field(latest_reader_view, "choice_impacts", []) or []) + if not raw_impacts: + raw_impacts = build_choice_impacts( + raw_choices, + reader_view=latest_reader_view if isinstance(latest_reader_view, dict) else None, + chapter_index=int(_story_get_field(latest_reader_view, "chapter_index", 0) or 0), + ) + choices = [] + for index, choice_text in enumerate(raw_choices, start=1): + branch_index = (index - 1) % len(QUANTUM_STORY_BRANCH_TYPES) + impact = dict(raw_impacts[index - 1] or {}) if index - 1 < len(raw_impacts) and isinstance(raw_impacts[index - 1], dict) else {} + choices.append( + { + "id": f"choice:{current_node_id}:{index}", + "text": str(choice_text or ""), + "description": str(scene_card.get("summary") or "继续这一幕"), + "impact": impact, + "branchType": QUANTUM_STORY_BRANCH_TYPES[branch_index], + "estimatedDeviation": _story_clamp_score(current_total + QUANTUM_STORY_DEVIATION_INCREMENTS[branch_index]), + "mentalCost": 10 + ((index - 1) * 5), + "preview": str(choice_text or ""), + "isPremium": False, + } + ) + return choices + + +def _story_deviation_payload(bundle: Dict[str, Any]) -> Dict[str, Any]: + story_chapters = list(bundle.get("chapter_rows") or bundle.get("steps") or []) + current_state = getattr(bundle["session_record"], "current_state", None) + previous_state = None + if story_chapters: + latest_step = story_chapters[-1] + current_state = _story_get_field(latest_step, "state_after", current_state) + previous_state = _story_get_field(latest_step, "state_before") + total_score = _story_total_deviation(current_state) + previous_total = _story_total_deviation(previous_state) if previous_state is not None else total_score + trend = "stable" + if total_score > previous_total: + trend = "increasing" + elif total_score < previous_total: + trend = "decreasing" + return { + "totalScore": total_score, + "breakdown": _story_breakdown_payload(current_state), + "trend": trend, + "maxPossible": 100, + "ifBranchCount": len(list(_story_get_field(current_state, "route_fingerprint", []) or [])), + "parallelWorlds": 1, + } + + +def _story_share_payload(bundle: Dict[str, Any], *, share_token_row: Dict[str, Any]) -> Dict[str, Any]: + share_node_id = str(share_token_row.get("node_id") or "").strip() + session_payload = _story_session_payload(bundle, truncate_at_node_id=share_node_id) + nodes_payload = _story_nodes_payload(bundle, truncate_at_node_id=share_node_id) + truncated_bundle = dict(bundle) + story_chapters = list(bundle.get("chapter_rows") or bundle.get("steps") or []) + if nodes_payload and len(story_chapters) >= len(nodes_payload): + state_after = _story_get_field(story_chapters[len(nodes_payload) - 1], "state_after", {}) + state_after_payload = state_after if isinstance(state_after, dict) else state_after.to_dict() + truncated_bundle["session_record"] = type(bundle["session_record"]).from_dict( + { + **bundle["session_record"].to_dict(), + "current_state": state_after_payload, + } + ) + if "chapter_rows" in bundle: + truncated_bundle["chapter_rows"] = story_chapters[: len(nodes_payload)] + else: + truncated_bundle["steps"] = story_chapters[: len(nodes_payload)] + deviation_payload = _story_deviation_payload(truncated_bundle) + return { + "session": session_payload, + "nodes": nodes_payload, + "deviation": deviation_payload, + "sharedAt": str(share_token_row.get("created_at") or ""), + "sharerName": str(share_token_row.get("sharer_name") or ""), + "shareToken": str(share_token_row.get("share_token") or ""), + } + + +def _story_bootstrap_session(request: Request, *, session_id: str, reader_id: Optional[str]) -> Dict[str, Any]: + session_record = request.app.state.repository.get_session(session_id) + world_version_id = str((session_record.metadata or {}).get("world_version_id") or "") + world_version = request.app.state.repository.get_world_version(world_version_id) + intents = [ + "进入故事。", + "更稳地进入故事。", + "先从眼前局势切入。", + ] + latest_result: Dict[str, Any] = {} + first_attempt_status: Optional[str] = None + final_intent = intents[0] + for attempt_index, intent in enumerate(intents, start=1): + final_intent = intent + request.app.state.analytics_service.track( + "story_import_bootstrap_attempted", + reader_id=reader_id, + session_id=session_id, + world_id=world_version.world_id, + world_version_id=world_version_id, + payload_json={ + "attempt_index": attempt_index, + "bootstrap_intent": intent, + }, + ) + latest_result = request.app.state.session_service.continue_story( + ReaderContinueCommand(session_id=session_id, freeform_intent=intent), + reader_id=reader_id, + ) + status = str(latest_result.get("status") or "") + if first_attempt_status is None: + first_attempt_status = status + if status == "quality_guard_failed" and intent != intents[-1]: + request.app.state.analytics_service.track( + "story_import_bootstrap_retry_applied", + reader_id=reader_id, + session_id=session_id, + world_id=world_version.world_id, + world_version_id=world_version_id, + payload_json={ + "attempt_index": attempt_index, + "bootstrap_intent": intent, + "result_status": status, + "recovered_after_retry": False, + }, + ) + if status != "quality_guard_failed": + break + final_status = str(latest_result.get("status") or "") + request.app.state.analytics_service.track( + "story_import_bootstrap_completed", + reader_id=reader_id, + session_id=session_id, + world_id=world_version.world_id, + world_version_id=world_version_id, + payload_json={ + "attempt_index": attempt_index, + "bootstrap_intent": final_intent, + "first_attempt_result_status": first_attempt_status or final_status, + "result_status": final_status, + "recovered_after_retry": (first_attempt_status == "quality_guard_failed" and final_status != "quality_guard_failed"), + }, + ) + return latest_result + + +def _reader_generation_scheduler(request: Request): + return getattr(request.app.state, "reader_generation_job_scheduler", None) + + +def _enqueue_reader_generation_job( + request: Request, + *, + operation: str, + session_id: str, + reader_id: Optional[str], + account_id: Optional[str], + choice_id: Optional[str] = None, + freeform_intent: Optional[str] = None, + steering_directive: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + return request.app.state.async_job_service.enqueue_job( + job_type=READER_GENERATION_JOB_TYPE, + payload={ + "operation": operation, + "session_id": session_id, + "reader_id": reader_id, + "account_id": account_id, + "choice_id": choice_id, + "freeform_intent": freeform_intent, + "steering_directive": steering_directive, + }, + requested_by=account_id or reader_id or "reader_guest", + account_id=account_id or reader_id, + schedule=_reader_generation_scheduler(request), + ) + + +def _story_generation_job_access(request: Request, *, job_id: str) -> Dict[str, Any]: + job = request.app.state.async_job_service.get_job(job_id) + if str(job.get("job_type") or "") != READER_GENERATION_JOB_TYPE: + raise KeyError(f"unknown_story_generation_job:{job_id}") + session_id = str((job.get("payload") or {}).get("session_id") or (job.get("result_summary") or {}).get("session_id") or "") + if session_id: + _story_session_bundle(request, session_id=session_id, limit=1, latest=True) + return job + + +def _story_generation_job_payload(request: Request, *, job: Dict[str, Any]) -> Dict[str, Any]: + payload = dict(job.get("payload") or {}) + result_summary = dict(job.get("result_summary") or {}) + session_id = str(payload.get("session_id") or result_summary.get("session_id") or "").strip() + operation = str(payload.get("operation") or result_summary.get("operation") or "").strip() + reader_status = str(result_summary.get("reader_status") or "").strip() or None + data: Dict[str, Any] = { + "jobId": job.get("job_id"), + "jobType": job.get("job_type"), + "operation": operation, + "status": job.get("status"), + "sessionId": session_id, + "readerStatus": reader_status, + "pollAfterMs": 1000 if job.get("status") in {"queued", "running"} else 0, + "retryable": job.get("status") in {"queued", "failed"} or job.get("lease_status") == "expired", + "attemptCount": job.get("attempt_count", 0), + "createdAt": job.get("created_at"), + "updatedAt": job.get("updated_at"), + "startedAt": job.get("started_at"), + "finishedAt": job.get("finished_at"), + "error": job.get("error"), + } + if job.get("status") != "succeeded": + return data + + if reader_status == "payment_required": + data["paywall"] = _story_paywall_payload(result_summary.get("paywall")) + data["continuityContract"] = _story_continuity_contract_payload(result_summary.get("continuity_contract")) + return data + if reader_status == "quality_guard_failed": + data["qualityGate"] = _story_quality_gate_payload(result_summary.get("quality_gate")) + data["continuityContract"] = _story_continuity_contract_payload(result_summary.get("continuity_contract")) + return data + + if session_id: + bundle = _story_session_bundle(request, session_id=session_id, limit=1, latest=True) + nodes = _story_nodes_payload(bundle) + session_payload = _story_session_payload(bundle) + data["session"] = session_payload + data["node"] = nodes[-1] if nodes else None + data["bootstrapStatus"] = reader_status or "ok" + if operation == "story_import_bootstrap": + data["launch"] = { + "mode": "start", + "sessionId": session_payload["id"], + "worldId": result_summary.get("world_id") or bundle["world_version"].world_id, + "worldVersionId": result_summary.get("world_version_id") or bundle["world_version"].world_version_id, + "bootstrapStatus": reader_status or "ok", + "handoffUrl": _story_import_handoff_url( + request, + session_id=session_payload["id"], + world_id=result_summary.get("world_id") or bundle["world_version"].world_id, + account_id=str(payload.get("account_id") or "").strip() or None, + ), + } + return data + +def _showcase_published_versions(request: Request) -> List[Dict[str, Any]]: + latest_by_world: Dict[str, Dict[str, Any]] = {} + for item in request.app.state.repository.list_world_versions(status="published"): + world_id = str(item.get("world_id") or "").strip() + if not world_id or world_id in latest_by_world: + continue + latest_by_world[world_id] = dict(item) + return list(latest_by_world.values()) + + +def _resolve_showcase_version(request: Request, work_id: str): + version = request.app.state.repository.get_world_version(work_id) + if str(version.status or "") != "published": + raise KeyError("showcase_work_missing") + return version + + +def _showcase_viewer_account_id(request: Request) -> Optional[str]: + identity = _maybe_identity(request) + if identity is None: + return None + return str(identity.get("account_id") or identity.get("actor_id") or "").strip() or None + + +def _showcase_interaction_maps( + request: Request, + *, + world_ids: List[str], + viewer_account_id: Optional[str] = None, +) -> Dict[str, Any]: + return request.app.state.quantum_read_model_service.showcase_interaction_maps( + world_ids=world_ids, + viewer_account_id=viewer_account_id, + ) + + +def _showcase_item_from_version( + request: Request, + *, + version_summary: Dict[str, Any], + hot_rank: Optional[int] = None, + like_counts: Optional[Dict[str, int]] = None, + comment_counts: Optional[Dict[str, int]] = None, + liked_world_ids: Optional[set[str]] = None, +) -> Dict[str, Any]: + return request.app.state.quantum_read_model_service.showcase_item_from_version( + version_summary=version_summary, + hot_rank=hot_rank, + interaction_maps={ + "like_counts": like_counts or {}, + "comment_counts": comment_counts or {}, + "liked_world_ids": liked_world_ids or set(), + "tip_totals": {}, + "view_counts": {}, + "impression_counts": {}, + }, + viewer_account_id=_showcase_viewer_account_id(request), + ) + + +def _showcase_works_payload(request: Request, *, sort_value: str, page: int, page_size: int) -> List[Dict[str, Any]]: + viewer_account_id = _showcase_viewer_account_id(request) + return request.app.state.quantum_read_model_service.showcase_works( + sort_value=sort_value, + page=page, + page_size=page_size, + viewer_account_id=viewer_account_id, + ) + + +def _showcase_comment_payload(comment: Dict[str, Any]) -> Dict[str, Any]: + return { + "id": str(comment.get("showcase_comment_id") or ""), + "authorName": str(comment.get("author_name") or comment.get("account_id") or "用户"), + "authorAvatar": "", + "content": str(comment.get("content") or ""), + "createdAt": str(comment.get("created_at") or ""), + "likeCount": 0, + } + + +def _frontend_soul_profile( + request: Optional[Request], + *, + user_id: str, + authenticated_account_id: Optional[str] = None, +) -> Dict[str, Any]: + if request is None: + return { + "userId": user_id, + "displayName": user_id, + "avatar": "", + "readingMileage": 0, + "ifBranchTriggered": 0, + "todayFocus": 0, + "level": 1, + "dimensions": _default_soul_dimensions(), + "preferences": {"genres": [], "styles": [], "privacyMode": "followers"}, + "recentSessions": [], + "viewerIsOwner": False, + "viewerHasFollowedAuthor": False, + } + viewer_identity = _maybe_identity(request) + return request.app.state.quantum_read_model_service.soul_profile( + user_id=user_id, + viewer_account_id=authenticated_account_id, + viewer_actor_id=str((viewer_identity or {}).get("actor_id") or "").strip() or None, + ) + + +def _frontend_auth_response( + request: Request, + *, + identity: Dict[str, Any], + access_token: str, + refresh_token: Optional[str], +) -> Dict[str, Any]: + return { + "user": _frontend_user(request, identity), + "token": access_token, + "refreshToken": str(refresh_token or ""), + } + + +@router.get("/health") +def quantum_health() -> Dict[str, Any]: + return {"status": "ok"} + + +@router.get("/soul/profile") +def quantum_soul_profile(request: Request) -> Dict[str, Any]: + identity = _maybe_identity(request) + user_id = "guest" + account_id = None + if identity is not None: + user_id = str(identity.get("account_id") or identity.get("actor_id") or "guest").strip() or "guest" + account_id = str(identity.get("account_id") or identity.get("actor_id") or "").strip() or None + return _success(data=_frontend_soul_profile(request, user_id=user_id, authenticated_account_id=account_id)) + + +@router.get("/soul/profile/{user_id}") +def quantum_soul_profile_by_id(user_id: str, request: Request) -> Dict[str, Any]: + normalized = str(user_id or "").strip() or "guest" + viewer_identity = _maybe_identity(request) + return _success( + data=request.app.state.quantum_read_model_service.soul_profile( + user_id=normalized, + viewer_account_id=str((viewer_identity or {}).get("account_id") or (viewer_identity or {}).get("actor_id") or "").strip() or None, + viewer_actor_id=str((viewer_identity or {}).get("actor_id") or "").strip() or None, + ) + ) + + +@router.put("/soul/preferences") +def quantum_soul_preferences(payload: QuantumSoulPreferencesRequest, request: Request) -> Any: + try: + identity = _require_identity(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + try: + updated = request.app.state.quantum_read_model_service.update_soul_preferences( + actor_id=str(identity.get("actor_id") or "").strip(), + account_id=_quantum_identity_account_id(identity) or None, + genres=payload.genres, + styles=payload.styles, + privacy_mode=payload.privacyMode, + ) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + return _success( + data={ + "genres": list(updated.get("genres") or []), + "styles": list(updated.get("styles") or []), + "privacyMode": str(updated.get("privacy_mode") or "followers"), + } + ) + + +@router.post("/auth/register") +def quantum_register(payload: QuantumAuthRegisterRequest, request: Request, response: Response) -> Any: + username = str(payload.username or "").strip() + email = str(payload.email or "").strip() + password = str(payload.password or "").strip() + display_name = str(payload.displayName or "").strip() + if not username: + return _error(status_code=400, message="username_required") + if not email: + return _error(status_code=400, message="email_required") + if not password: + return _error(status_code=400, message="password_required") + try: + _ensure_registration_available(request, username=username, email=email) + request.app.state.auth_service.register_identity( + actor_id=username, + actor_role="author", + password=password, + account_id=email, + display_name=display_name or username, + ) + token_result = request.app.state.auth_service.issue_token(actor_id=username, password=password) + response.set_cookie( + value=token_result["token"]["access_token"], + **request.app.state.auth_service.auth_cookie_settings(), + ) + return _success( + data=_frontend_auth_response( + request, + identity=token_result["identity"], + access_token=token_result["token"]["access_token"], + refresh_token=(token_result.get("refresh") or {}).get("refresh_token"), + ) + ) + except AuthServiceError as exc: + detail = exc.detail() + return _error(status_code=exc.http_status, message=str(detail.get("reason") or detail.get("code") or "auth_error"), data=detail) + except ValueError as exc: + message = str(exc) + status_code = 409 if message in {"username_already_registered", "email_already_registered"} else 400 + return _error(status_code=status_code, message=message) + except Exception as exc: # pragma: no cover - defensive fallback + return _error(status_code=500, message=str(exc) or "compat_auth_register_failed") + + +@router.post("/auth/login") +def quantum_login(payload: QuantumAuthLoginRequest, request: Request, response: Response) -> Any: + try: + actor_id = _resolve_login_actor_id(request, payload.identifier) + token_result = request.app.state.auth_service.issue_token(actor_id=actor_id, password=payload.password) + response.set_cookie( + value=token_result["token"]["access_token"], + **request.app.state.auth_service.auth_cookie_settings(), + ) + return _success( + data=_frontend_auth_response( + request, + identity=token_result["identity"], + access_token=token_result["token"]["access_token"], + refresh_token=(token_result.get("refresh") or {}).get("refresh_token"), + ) + ) + except AuthServiceError as exc: + detail = exc.detail() + return _error(status_code=exc.http_status, message=str(detail.get("reason") or detail.get("code") or "auth_error"), data=detail) + except PermissionError as exc: + return _error(status_code=401, message=str(exc) or "auth_login_failed") + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "auth_identity_missing") + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + + +@router.post("/auth/refresh") +def quantum_refresh(payload: QuantumAuthRefreshRequest, request: Request, response: Response) -> Any: + try: + token_result = request.app.state.auth_service.refresh_access_token(raw_refresh_token=payload.refreshToken) + response.set_cookie( + value=token_result["token"]["access_token"], + **request.app.state.auth_service.auth_cookie_settings(), + ) + return _success( + data=_frontend_auth_response( + request, + identity=token_result["identity"], + access_token=token_result["token"]["access_token"], + refresh_token=(token_result.get("refresh") or {}).get("refresh_token"), + ) + ) + except AuthServiceError as exc: + detail = exc.detail() + return _error(status_code=exc.http_status, message=str(detail.get("reason") or detail.get("code") or "auth_error"), data=detail) + except PermissionError as exc: + return _error(status_code=401, message=str(exc) or "auth_refresh_failed") + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "auth_refresh_missing") + + +@router.get("/auth/me") +def quantum_me(request: Request) -> Any: + raw_token = _request_token(request) + if not raw_token: + return _error(status_code=401, message="missing_bearer_token") + try: + identity = request.app.state.auth_service.resolve_bearer_token(raw_token) + return _success(data=_frontend_user(request, identity)) + except PermissionError as exc: + return _error(status_code=401, message=str(exc) or "auth_token_invalid") + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "auth_identity_missing") + + +@router.put("/auth/profile") +def quantum_update_profile(payload: QuantumAuthProfileUpdateRequest, request: Request) -> Any: + raw_token = _request_token(request) + if not raw_token: + return _error(status_code=401, message="missing_bearer_token") + try: + identity = request.app.state.auth_service.resolve_bearer_token(raw_token) + updated = request.app.state.auth_service.update_profile( + actor_id=str(identity.get("actor_id") or ""), + display_name=payload.displayName, + avatar_url=payload.avatar, + email_address=payload.email, + ) + return _success(data=_frontend_user(request, updated["identity"])) + except AuthServiceError as exc: + detail = exc.detail() + return _error(status_code=exc.http_status, message=str(detail.get("reason") or detail.get("code") or "auth_error"), data=detail) + except PermissionError as exc: + return _error(status_code=401, message=str(exc) or "auth_token_invalid") + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "auth_identity_missing") + + +@router.post("/auth/logout") +def quantum_logout(request: Request, response: Response) -> Dict[str, Any]: + raw_token = _request_token(request) + if raw_token: + try: + request.app.state.auth_service.revoke_bearer_token(raw_token) + except (PermissionError, KeyError): + pass + response.delete_cookie( + key=request.app.state.auth_service.auth_cookie_settings()["key"], + path="/", + domain=request.app.state.auth_service.auth_cookie_settings().get("domain"), + ) + return _success(data=None) + + +@router.get("/settings/membership/plans") +def quantum_membership_plans(request: Request) -> Dict[str, Any]: + return _success(data=_membership_plan_catalog(request)) + + +@router.get("/settings/ink/packages") +def quantum_ink_packages(request: Request) -> Dict[str, Any]: + return _success(data=_ink_packages_catalog(request)) + + +@router.get("/settings/preferences") +def quantum_settings_preferences(request: Request) -> Any: + try: + identity = _require_identity(request) + settings = request.app.state.auth_service.get_user_settings(actor_id=str(identity.get("actor_id") or "")) + return _success(data=settings) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "auth_identity_missing") + + +@router.put("/settings/preferences") +def quantum_update_settings_preferences(payload: QuantumSettingsPreferencesRequest, request: Request) -> Any: + try: + identity = _require_identity(request) + settings = request.app.state.auth_service.update_user_settings( + actor_id=str(identity.get("actor_id") or ""), + settings_updates=payload.model_dump(exclude_none=True) if hasattr(payload, "model_dump") else payload.dict(exclude_none=True), + ) + return _success(data=settings) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "auth_identity_missing") + + +@router.get("/ops/bootstrap") +def quantum_ops_bootstrap(request: Request) -> Any: + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + return _success(data=_quantum_ops_bootstrap_payload(request, identity=identity)) + + +@router.get("/ops/workspaces/reviewer") +def quantum_ops_reviewer_workspace(request: Request, reviewItemId: Optional[str] = None, limit: int = 40) -> Any: + try: + _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + return _success( + data=_quantum_ops_reviewer_workspace_payload( + request, + selected_review_item_id=reviewItemId, + limit=max(1, min(100, int(limit or 40))), + ) + ) + + +@router.get("/ops/workspaces/runtime") +def quantum_ops_runtime_workspace( + request: Request, + accountId: Optional[str] = None, + worldId: Optional[str] = None, + limit: int = 20, +) -> Any: + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + resolved_account_id = str(accountId or "").strip() or _quantum_identity_account_id(identity) or None + return _success( + data=_quantum_ops_runtime_workspace_payload( + request, + account_id=resolved_account_id, + world_id=worldId, + limit=max(1, min(100, int(limit or 20))), + ) + ) + + +@router.get("/ops/workspaces/account") +def quantum_ops_account_workspace(request: Request, accountId: Optional[str] = None, limit: int = 12) -> Any: + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + resolved_account_id = str(accountId or "").strip() or _quantum_identity_account_id(identity) + if not resolved_account_id: + return _error(status_code=400, message="ops_account_required") + return _success( + data=_quantum_ops_account_workspace_payload( + request, + account_id=resolved_account_id, + limit=max(1, min(100, int(limit or 12))), + ) + ) + + +@router.get("/ops/workspaces/release") +def quantum_ops_release_workspace(request: Request, worldId: Optional[str] = None, limit: int = 12) -> Any: + try: + _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + return _success( + data=_quantum_ops_release_workspace_payload( + request, + world_id=worldId, + limit=max(1, min(100, int(limit or 12))), + ) + ) + + +@router.get("/ops/workspaces/alerts") +def quantum_ops_alerts_workspace(request: Request, accountId: Optional[str] = None, alertId: Optional[str] = None, limit: int = 20) -> Any: + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + resolved_account_id = str(accountId or "").strip() or _quantum_identity_account_id(identity) or None + return _success( + data=_quantum_ops_alerts_workspace_payload( + request, + account_id=resolved_account_id, + alert_id=alertId, + limit=max(1, min(100, int(limit or 20))), + ) + ) + + +@router.get("/ops/workspaces/learned") +def quantum_ops_learned_workspace(request: Request, worldId: Optional[str] = None, issueCode: Optional[str] = None, limit: int = 20) -> Any: + try: + _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + return _success( + data=_quantum_ops_learned_workspace_payload( + request, + world_id=worldId, + issue_code=issueCode, + limit=max(1, min(100, int(limit or 20))), + ) + ) + + +@router.get("/ops/workspaces/governance") +def quantum_ops_governance_workspace(request: Request, accountId: Optional[str] = None, caseId: Optional[str] = None, limit: int = 20) -> Any: + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + resolved_account_id = str(accountId or "").strip() or _quantum_identity_account_id(identity) or None + return _success( + data=_quantum_ops_governance_workspace_payload( + request, + account_id=resolved_account_id, + case_id=caseId, + limit=max(1, min(100, int(limit or 20))), + ) + ) + + +@router.get("/ops/workspaces/governance/queue") +def quantum_ops_governance_queue_workspace( + request: Request, + status: Optional[str] = None, + ownerId: Optional[str] = None, + caseType: Optional[str] = None, + severity: Optional[str] = None, + targetType: Optional[str] = None, + hasActiveRestriction: Optional[bool] = None, + overdueOnly: bool = False, + unassignedOnly: bool = False, + search: Optional[str] = None, + selectedCaseIds: Optional[str] = None, + limit: int = 100, +) -> Any: + try: + _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + return _success( + data=_quantum_ops_governance_queue_workspace_payload( + request, + status=str(status or "").strip() or None, + owner_id=str(ownerId or "").strip() or None, + case_type=str(caseType or "").strip() or None, + severity=str(severity or "").strip() or None, + target_type=str(targetType or "").strip() or None, + has_active_restriction=hasActiveRestriction, + overdue_only=bool(overdueOnly), + unassigned_only=bool(unassignedOnly), + search=str(search or "").strip() or None, + selected_case_ids=[item.strip() for item in str(selectedCaseIds or "").split(",") if item.strip()], + limit=max(1, min(200, int(limit or 100))), + ) + ) + + +@router.get("/ops/workspaces/governance/cases/{caseId}/restriction-history") +def quantum_ops_governance_case_restriction_history(caseId: str, request: Request, limit: int = 20) -> Any: + try: + _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + try: + return _success(data=request.app.state.governance_service.restriction_history(caseId, limit=max(1, min(100, int(limit or 20))))) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + + +@router.put("/ops/workspaces/governance/capacity/owners/{ownerId}") +def quantum_ops_update_governance_capacity_override(ownerId: str, payload: QuantumOpsGovernanceCapacityOverrideRequest, request: Request) -> Any: + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + if str(identity.get("actor_role") or "").strip() != "admin": + return _error(status_code=403, message="governance_capacity_admin_required") + try: + request.app.state.governance_service.update_capacity_override( + ownerId, + capacity_units_per_day=payload.capacityUnitsPerDay, + critical_case_limit=payload.criticalCaseLimit, + active_restriction_limit=payload.activeRestrictionLimit, + sla_hours=payload.slaHours, + role_multiplier=payload.roleMultiplier, + enabled=payload.enabled, + clear_override=bool(payload.clearOverride), + reviewer_id=str(identity.get("actor_id") or "").strip() or None, + actor_role=str(identity.get("actor_role") or "").strip() or None, + note=str(payload.note or "").strip() or None, + source_surface="quantum_ops", + ) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + return _success( + data=_quantum_ops_governance_queue_workspace_payload( + request, + status=None, + owner_id=None, + case_type=None, + severity=None, + target_type=None, + has_active_restriction=None, + overdue_only=False, + unassigned_only=False, + search=None, + selected_case_ids=None, + limit=100, + ) + ) + + +@router.post("/ops/workspaces/governance/cases") +def quantum_ops_create_governance_case(payload: QuantumOpsGovernanceCreateCaseRequest, request: Request) -> Any: + summary = str(payload.summary or "").strip() + if not summary: + return _error(status_code=400, message="governance_case_summary_required") + case_type = str(payload.caseType or "").strip() + target_type = str(payload.targetType or "").strip() + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + + resolved_account_id = str(payload.accountId or "").strip() or None + resolved_target_id = str(payload.targetId or "").strip() or None + if target_type == "account": + resolved_target_id = resolved_target_id or resolved_account_id + resolved_account_id = resolved_account_id or resolved_target_id + if not resolved_target_id: + return _error(status_code=400, message="governance_case_target_required") + elif not resolved_account_id: + return _error(status_code=400, message="ops_account_required") + elif not resolved_target_id: + return _error(status_code=400, message="governance_case_target_required") + + apply_restriction = payload.applyRestriction or QuantumOpsGovernanceRestrictionConfig() + actor_id = str(identity.get("actor_id") or "").strip() + actor_role = str(identity.get("actor_role") or "").strip() or None + canonical_payload: Dict[str, Any] = { + "case_type": case_type, + "target_type": target_type, + "target_id": resolved_target_id, + "account_id": resolved_account_id, + "due_at": str(payload.dueAt or "").strip() or None, + "severity": str(payload.severity or "medium").strip() or "medium", + "summary": summary, + "description": str(payload.description or "").strip() or None, + "reviewer_id": actor_id or None, + "owner_id": actor_id or None, + "policy_labels": list(payload.policyLabels or []), + "support_issue_ids": list(payload.supportIssueIds or []), + "source": "quantum_ops", + "actor_role": actor_role, + "source_surface": "quantum_ops", + } + if target_type == "world_version": + canonical_payload["world_version_id"] = resolved_target_id + elif target_type == "session": + canonical_payload["session_id"] = resolved_target_id + elif target_type == "entitlement": + canonical_payload["entitlement_id"] = resolved_target_id + + try: + if apply_restriction.enabled: + if not resolved_account_id: + return _error(status_code=400, message="ops_account_required") + restriction_type = str(apply_restriction.restrictionType or "").strip() + if not restriction_type: + return _error(status_code=400, message="governance_restriction_type_required") + case = request.app.state.governance_service.apply_restriction( + { + **canonical_payload, + "restriction_type": restriction_type, + "expires_at": str(apply_restriction.expiresAt or "").strip() or None, + "restriction_reason": str(apply_restriction.restrictionReason or "").strip() or None, + } + ) + else: + case = request.app.state.governance_service.create_case(canonical_payload) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + + return _success( + data=_quantum_ops_governance_workspace_payload( + request, + account_id=resolved_account_id, + case_id=str(case.get("case_id") or ""), + limit=20, + ) + ) + + +@router.post("/ops/workspaces/governance/restrictions") +def quantum_ops_apply_governance_restriction(payload: QuantumOpsGovernanceApplyRestrictionRequest, request: Request) -> Any: + summary = str(payload.summary or "").strip() + if not summary: + return _error(status_code=400, message="governance_restriction_summary_required") + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + resolved_account_id = str(payload.accountId or "").strip() or _quantum_identity_account_id(identity) or None + if not resolved_account_id: + return _error(status_code=400, message="ops_account_required") + try: + case = request.app.state.governance_service.apply_restriction( + { + "restriction_type": str(payload.restrictionType or "").strip(), + "account_id": resolved_account_id, + "case_type": "abuse", + "severity": str(payload.severity or "high").strip() or "high", + "summary": summary, + "description": str(payload.description or "").strip() or None, + "reviewer_id": str(identity.get("actor_id") or "").strip() or None, + "actor_role": str(identity.get("actor_role") or "").strip() or None, + "expires_at": str(payload.expiresAt or "").strip() or None, + "restriction_reason": str(payload.restrictionReason or "").strip() or None, + "support_issue_ids": list(payload.supportIssueIds or []), + "source_surface": "quantum_ops", + } + ) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + return _success( + data=_quantum_ops_governance_workspace_payload( + request, + account_id=resolved_account_id, + case_id=str(case.get("case_id") or ""), + limit=20, + ) + ) + + +@router.post("/ops/workspaces/governance/cases/{caseId}/apply-restriction") +def quantum_ops_apply_governance_case_restriction(caseId: str, payload: QuantumOpsGovernanceCaseRestrictionRequest, request: Request) -> Any: + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + resolved_account_id = str(payload.accountId or "").strip() or _quantum_identity_account_id(identity) or None + try: + case = request.app.state.governance_service.apply_case_restriction( + caseId, + restriction_type=str(payload.restrictionType or "").strip(), + reviewer_id=str(identity.get("actor_id") or "").strip() or None, + actor_role=str(identity.get("actor_role") or "").strip() or None, + restriction_reason=str(payload.restrictionReason or "").strip() or None, + expires_at=str(payload.expiresAt or "").strip() or None, + source_surface="quantum_ops", + ) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + except PermissionError as exc: + return _error(status_code=403, message=str(exc)) + return _success( + data=_quantum_ops_governance_workspace_payload( + request, + account_id=resolved_account_id, + case_id=str(case.get("case_id") or caseId), + limit=20, + ) + ) + + +@router.post("/ops/workspaces/alerts/{alertId}/acknowledge") +def quantum_ops_acknowledge_alert(alertId: str, payload: QuantumOpsAlertMutationRequest, request: Request) -> Any: + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + resolved_account_id = str(payload.accountId or "").strip() or _quantum_identity_account_id(identity) or None + if str(alertId).startswith("quantum_ops_light::"): + request.app.state.commercial_audit_service.record_audit_log( + actor_id=str(identity.get("actor_id") or "").strip() or "ops_unknown", + actor_role=str(identity.get("actor_role") or "admin"), + account_id=resolved_account_id, + object_type="ops_alert", + object_id=alertId, + action_type="ops_alert_acknowledged", + source_surface="quantum_ops", + customer_visible_payload={"status": "acknowledged", "summary": "Remote Ops lightweight alert"}, + internal_payload={"note": payload.note, "alert_id": alertId}, + ) + else: + try: + request.app.state.ops_alerting_service.update_alert_status( + alertId, + status="acknowledged", + reviewer_id=str(identity.get("actor_id") or "").strip() or None, + actor_role=str(identity.get("actor_role") or "").strip() or None, + note=payload.note, + account_id=resolved_account_id, + source_surface="quantum_ops", + ) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + return _success( + data=_quantum_ops_alerts_workspace_payload( + request, + account_id=resolved_account_id, + alert_id=alertId, + limit=20, + ) + ) + + +@router.post("/ops/workspaces/alerts/{alertId}/resolve") +def quantum_ops_resolve_alert(alertId: str, payload: QuantumOpsAlertMutationRequest, request: Request) -> Any: + if not str(payload.note or "").strip(): + return _error(status_code=400, message="alert_resolution_note_required") + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + resolved_account_id = str(payload.accountId or "").strip() or _quantum_identity_account_id(identity) or None + if str(alertId).startswith("quantum_ops_light::"): + request.app.state.commercial_audit_service.record_audit_log( + actor_id=str(identity.get("actor_id") or "").strip() or "ops_unknown", + actor_role=str(identity.get("actor_role") or "admin"), + account_id=resolved_account_id, + object_type="ops_alert", + object_id=alertId, + action_type="ops_alert_resolved", + source_surface="quantum_ops", + customer_visible_payload={"status": "resolved", "summary": "Remote Ops lightweight alert"}, + internal_payload={"note": payload.note, "alert_id": alertId}, + ) + else: + try: + request.app.state.ops_alerting_service.update_alert_status( + alertId, + status="resolved", + reviewer_id=str(identity.get("actor_id") or "").strip() or None, + actor_role=str(identity.get("actor_role") or "").strip() or None, + note=payload.note, + account_id=resolved_account_id, + source_surface="quantum_ops", + ) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + return _success( + data=_quantum_ops_alerts_workspace_payload( + request, + account_id=resolved_account_id, + alert_id=alertId, + limit=20, + ) + ) + + +@router.post("/ops/workspaces/governance/cases/{caseId}/assign") +def quantum_ops_assign_governance_case(caseId: str, payload: QuantumOpsGovernanceAssignRequest, request: Request) -> Any: + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + resolved_account_id = str(payload.accountId or "").strip() or _quantum_identity_account_id(identity) or None + actor_id = str(identity.get("actor_id") or "").strip() + try: + request.app.state.governance_service.assign_case( + caseId, + owner_id=str(payload.ownerId or "").strip() or actor_id, + reviewer_id=actor_id, + actor_role=str(identity.get("actor_role") or "").strip() or None, + due_at=str(payload.dueAt or "").strip() or None, + note=payload.note, + source_surface="quantum_ops", + ) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + except PermissionError as exc: + return _error(status_code=403, message=str(exc)) + return _success( + data=_quantum_ops_governance_workspace_payload( + request, + account_id=resolved_account_id, + case_id=caseId, + limit=20, + ) + ) + + +@router.patch("/ops/workspaces/governance/restrictions/{restrictionId}") +def quantum_ops_update_governance_restriction(restrictionId: str, payload: QuantumOpsGovernanceRestrictionUpdateRequest, request: Request) -> Any: + if payload.restrictionType is None and payload.restrictionReason is None and payload.expiresAt is None: + return _error(status_code=400, message="governance_restriction_update_empty") + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + resolved_account_id = str(payload.accountId or "").strip() or _quantum_identity_account_id(identity) or None + try: + case = request.app.state.governance_service.update_restriction( + restrictionId, + reviewer_id=str(identity.get("actor_id") or "").strip() or None, + actor_role=str(identity.get("actor_role") or "").strip() or None, + restriction_type=str(payload.restrictionType or "").strip() or None, + restriction_reason=str(payload.restrictionReason or "").strip() or None, + expires_at=payload.expiresAt, + source_surface="quantum_ops", + ) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + except PermissionError as exc: + return _error(status_code=403, message=str(exc)) + return _success( + data=_quantum_ops_governance_workspace_payload( + request, + account_id=resolved_account_id, + case_id=str(case.get("case_id") or ""), + limit=20, + ) + ) + + +@router.post("/ops/workspaces/governance/cases/{caseId}/status") +def quantum_ops_update_governance_case_status(caseId: str, payload: QuantumOpsGovernanceStatusRequest, request: Request) -> Any: + normalized_status = str(payload.status or "").strip() + if normalized_status in {"resolved", "dismissed"} and not str(payload.resolutionNotes or "").strip(): + return _error(status_code=400, message="resolution_notes_required") + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + resolved_account_id = str(payload.accountId or "").strip() or _quantum_identity_account_id(identity) or None + actor_id = str(identity.get("actor_id") or "").strip() + try: + request.app.state.governance_service.update_case_status( + caseId, + status=normalized_status, + reviewer_id=actor_id, + actor_role=str(identity.get("actor_role") or "").strip() or None, + resolution_notes=payload.resolutionNotes, + disposition=payload.disposition, + source_surface="quantum_ops", + ) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + except PermissionError as exc: + return _error(status_code=403, message=str(exc)) + return _success( + data=_quantum_ops_governance_workspace_payload( + request, + account_id=resolved_account_id, + case_id=caseId, + limit=20, + ) + ) + + +@router.post("/ops/workspaces/governance/cases/{caseId}/evidence") +def quantum_ops_append_governance_case_evidence(caseId: str, payload: QuantumOpsGovernanceEvidenceRequest, request: Request) -> Any: + title = str(payload.title or "").strip() + preview = str(payload.preview or "").strip() + if not title: + return _error(status_code=400, message="governance_evidence_title_required") + if not preview: + return _error(status_code=400, message="governance_evidence_preview_required") + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + resolved_account_id = str(payload.accountId or "").strip() or _quantum_identity_account_id(identity) or None + actor_id = str(identity.get("actor_id") or "").strip() + try: + request.app.state.governance_service.append_case_evidence( + caseId, + reviewer_id=actor_id, + actor_role=str(identity.get("actor_role") or "").strip() or None, + title=title, + preview=preview, + ref_id=str(payload.refId or "").strip() or None, + kind=str(payload.kind or "note").strip() or "note", + source_surface="quantum_ops", + ) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + except PermissionError as exc: + return _error(status_code=403, message=str(exc)) + return _success( + data=_quantum_ops_governance_workspace_payload( + request, + account_id=resolved_account_id, + case_id=caseId, + limit=20, + ) + ) + + +@router.post("/ops/workspaces/governance/cases/{caseId}/release-restriction") +def quantum_ops_release_governance_case_restriction(caseId: str, payload: QuantumOpsGovernanceRestrictionReleaseRequest, request: Request) -> Any: + release_reason = str(payload.releaseReason or "").strip() + if not release_reason: + return _error(status_code=400, message="governance_restriction_release_reason_required") + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + resolved_account_id = str(payload.accountId or "").strip() or _quantum_identity_account_id(identity) or None + try: + selected_case_detail = dict( + request.app.state.governance_service.case_detail( + caseId, + actor_id=str(identity.get("actor_id") or "").strip() or None, + actor_role=str(identity.get("actor_role") or "").strip() or None, + ) + ) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + restriction = dict(selected_case_detail.get("restriction") or {}) + restriction_id = str(restriction.get("restriction_id") or "").strip() + if not restriction_id or str(restriction.get("status") or "") != "active": + return _error(status_code=400, message="governance_case_restriction_missing") + try: + request.app.state.governance_service.release_restriction( + restriction_id, + reviewer_id=str(identity.get("actor_id") or "").strip() or None, + actor_role=str(identity.get("actor_role") or "").strip() or None, + release_reason=release_reason, + source_surface="quantum_ops", + ) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except PermissionError as exc: + return _error(status_code=403, message=str(exc)) + return _success( + data=_quantum_ops_governance_workspace_payload( + request, + account_id=resolved_account_id, + case_id=caseId, + limit=20, + ) + ) + + +@router.post("/ops/workspaces/governance/bulk/preview") +def quantum_ops_governance_bulk_preview(payload: QuantumOpsGovernanceBulkActionRequest, request: Request) -> Any: + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + canonical_payload = { + "owner_id": str(payload.ownerId or "").strip() or None, + "owner_assignments": dict(payload.ownerAssignments or {}), + "due_at": str(payload.dueAt or "").strip() or None, + "note": str(payload.note or "").strip() or None, + "status": str(payload.status or "").strip() or None, + "resolution_notes": str(payload.resolutionNotes or "").strip() or None, + "disposition": str(payload.disposition or "").strip() or None, + "policy_labels": list(payload.policyLabels or []), + "restriction_type": str(payload.restrictionType or "").strip() or None, + "restriction_reason": str(payload.restrictionReason or "").strip() or None, + "expires_at": str(payload.expiresAt or "").strip() or None, + } + try: + preview = request.app.state.governance_service.bulk_action_preview( + case_ids=list(payload.caseIds or []), + action=str(payload.action or "").strip(), + payload=canonical_payload, + reviewer_id=str(identity.get("actor_id") or "").strip() or None, + actor_role=str(identity.get("actor_role") or "").strip() or None, + ) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + return _success( + data=_quantum_ops_governance_queue_workspace_payload( + request, + status=None, + owner_id=None, + case_type=None, + severity=None, + target_type=None, + has_active_restriction=None, + overdue_only=False, + unassigned_only=False, + search=None, + selected_case_ids=list(payload.caseIds or []), + limit=100, + bulk_preview_result=preview, + ) + ) + + +@router.post("/ops/workspaces/governance/bulk/execute") +def quantum_ops_governance_bulk_execute(payload: QuantumOpsGovernanceBulkActionRequest, request: Request) -> Any: + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + canonical_payload = { + "owner_id": str(payload.ownerId or "").strip() or None, + "owner_assignments": dict(payload.ownerAssignments or {}), + "due_at": str(payload.dueAt or "").strip() or None, + "note": str(payload.note or "").strip() or None, + "status": str(payload.status or "").strip() or None, + "resolution_notes": str(payload.resolutionNotes or "").strip() or None, + "disposition": str(payload.disposition or "").strip() or None, + "policy_labels": list(payload.policyLabels or []), + "restriction_type": str(payload.restrictionType or "").strip() or None, + "restriction_reason": str(payload.restrictionReason or "").strip() or None, + "expires_at": str(payload.expiresAt or "").strip() or None, + } + try: + execution = request.app.state.governance_service.bulk_action_execute( + case_ids=list(payload.caseIds or []), + action=str(payload.action or "").strip(), + payload=canonical_payload, + reviewer_id=str(identity.get("actor_id") or "").strip() or None, + actor_role=str(identity.get("actor_role") or "").strip() or None, + source_surface="quantum_ops", + ) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + except PermissionError as exc: + return _error(status_code=403, message=str(exc)) + return _success( + data=_quantum_ops_governance_queue_workspace_payload( + request, + status=None, + owner_id=None, + case_type=None, + severity=None, + target_type=None, + has_active_restriction=None, + overdue_only=False, + unassigned_only=False, + search=None, + selected_case_ids=list(payload.caseIds or []), + limit=100, + bulk_execution_result=execution, + ) + ) + + +@router.post("/ops/workspaces/reviewer/review-items/{reviewItemId}/assign") +def quantum_ops_assign_review_item(reviewItemId: str, request: Request, payload: Optional[QuantumOpsReviewAssignRequest] = None) -> Any: + del payload + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + reviewer_id = str(identity.get("actor_id") or "").strip() + try: + request.app.state.ops_review_hub_service.assign_review_item( + review_item_id=reviewItemId, + owner_id=reviewer_id, + reviewer_id=reviewer_id, + ) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + return _success(data=_quantum_ops_reviewer_workspace_payload(request, selected_review_item_id=reviewItemId, limit=40)) + + +@router.post("/ops/workspaces/reviewer/review-items/{reviewItemId}/status") +def quantum_ops_update_review_item_status(reviewItemId: str, payload: QuantumOpsReviewStatusRequest, request: Request) -> Any: + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + reviewer_id = str(identity.get("actor_id") or "").strip() + try: + request.app.state.ops_review_hub_service.update_review_item_status( + review_item_id=reviewItemId, + status=payload.status, + reviewer_id=reviewer_id, + ) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + return _success(data=_quantum_ops_reviewer_workspace_payload(request, selected_review_item_id=reviewItemId, limit=40)) + + +@router.post("/ops/workspaces/reviewer/review-items/{reviewItemId}/decision") +def quantum_ops_decide_review_item(reviewItemId: str, payload: QuantumOpsReviewDecisionRequest, request: Request) -> Any: + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + reviewer_id = str(identity.get("actor_id") or "").strip() + try: + request.app.state.ops_review_hub_service.decide_review_item( + review_item_id=reviewItemId, + decision=payload.decision, + reviewer_id=reviewer_id, + ) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + return _success(data=_quantum_ops_reviewer_workspace_payload(request, selected_review_item_id=reviewItemId, limit=40)) + + +@router.post("/ops/workspaces/account/{accountId}/grant-subscription") +def quantum_ops_grant_subscription(accountId: str, payload: QuantumOpsGrantSubscriptionRequest, request: Request) -> Any: + try: + _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + subscription = request.app.state.billing_service.grant_subscription( + { + "account_id": accountId, + "tier_id": payload.tierId, + "provider": "ops_manual", + "status": "active", + } + ) + request.app.state.analytics_service.track( + "subscription_activated", + reader_id=accountId, + account_id=accountId, + access_tier=subscription.get("tier_id"), + payload_json=subscription, + ) + return _success(data=_quantum_ops_account_workspace_payload(request, account_id=accountId, limit=12)) + + +@router.post("/ops/workspaces/account/{accountId}/grant-wallet") +def quantum_ops_grant_wallet(accountId: str, payload: QuantumOpsGrantWalletRequest, request: Request) -> Any: + try: + _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + entitlement = request.app.state.billing_service.grant_wallet_credits( + account_id=accountId, + wallet_type=payload.walletType, + amount=payload.amount, + tier_id=payload.tierId, + ) + request.app.state.analytics_service.track( + "entitlement_granted", + reader_id=accountId, + account_id=accountId, + access_tier=payload.tierId, + payload_json=entitlement, + ) + return _success(data=_quantum_ops_account_workspace_payload(request, account_id=accountId, limit=12)) + + +@router.post("/ops/workspaces/account/{accountId}/retry-subscription-payment") +def quantum_ops_retry_subscription_payment(accountId: str, request: Request) -> Any: + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + account_payload = _quantum_ops_account_workspace_payload(request, account_id=accountId, limit=12) + subscription_id = _quantum_ops_primary_subscription_id(dict(account_payload.get("accountDetail") or {})) + if not subscription_id: + return _error(status_code=400, message="ops_account_subscription_missing") + try: + retried = request.app.state.billing_service.retry_subscription_payment(subscription_id=subscription_id) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + request.app.state.analytics_service.track( + "subscription_retry_requested", + reader_id=accountId, + account_id=accountId, + payload_json={"subscription_id": subscription_id, "requested_by": str(identity.get("actor_id") or ""), **retried}, + ) + return _success(data=_quantum_ops_account_workspace_payload(request, account_id=accountId, limit=12)) + + +@router.post("/ops/workspaces/account/{accountId}/reconcile-subscription") +def quantum_ops_reconcile_subscription(accountId: str, request: Request) -> Any: + try: + identity = _require_quantum_ops_actor(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=403, message=str(exc)) + account_payload = _quantum_ops_account_workspace_payload(request, account_id=accountId, limit=12) + subscription_id = _quantum_ops_primary_subscription_id(dict(account_payload.get("accountDetail") or {})) + if not subscription_id: + return _error(status_code=400, message="ops_account_subscription_missing") + try: + reconciled = request.app.state.billing_service.reconcile_subscription(subscription_id) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + request.app.state.analytics_service.track( + "subscription_reconcile_requested", + reader_id=accountId, + account_id=accountId, + payload_json={"subscription_id": subscription_id, "requested_by": str(identity.get("actor_id") or ""), **reconciled}, + ) + return _success(data=_quantum_ops_account_workspace_payload(request, account_id=accountId, limit=12)) + + +@router.get("/studio/projects/{project_id}") +def quantum_studio_project(project_id: str, request: Request) -> Any: + try: + identity = _require_identity(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + try: + draft_detail = request.app.state.authoring_service.get_draft(project_id) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "studio_project_missing") + try: + _ensure_studio_project_owner(identity, draft_detail) + except PermissionError as exc: + return _error(status_code=403, message=str(exc)) + return _success(data=request.app.state.author_project_graph_service.project_payload(project_id=project_id, draft_detail=draft_detail)) + + +@router.post("/studio/projects/{project_id}/nodes") +def quantum_studio_add_node(project_id: str, payload: QuantumStudioNodeCreateRequest, request: Request) -> Any: + if str(payload.type or "").strip() not in {"root", "branch", "end"}: + return _error(status_code=400, message="studio_node_type_invalid") + try: + identity = _require_identity(request) + draft_detail = request.app.state.authoring_service.get_draft(project_id) + _ensure_studio_project_owner(identity, draft_detail) + except PermissionError as exc: + return _error(status_code=401 if str(exc) == "missing_bearer_token" else 403, message=str(exc)) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "studio_project_missing") + + updated = request.app.state.author_project_graph_service.add_node( + project_id=project_id, + draft_detail=draft_detail, + payload=payload.model_dump(), + ) + return _success(data=updated) + + +@router.put("/studio/projects/{project_id}/nodes/{node_id}") +def quantum_studio_update_node(project_id: str, node_id: str, payload: QuantumStudioNodeUpdateRequest, request: Request) -> Any: + try: + identity = _require_identity(request) + draft_detail = request.app.state.authoring_service.get_draft(project_id) + _ensure_studio_project_owner(identity, draft_detail) + except PermissionError as exc: + return _error(status_code=401 if str(exc) == "missing_bearer_token" else 403, message=str(exc)) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "studio_project_missing") + + try: + updated = request.app.state.author_project_graph_service.update_node( + project_id=project_id, + draft_detail=draft_detail, + node_id=node_id, + payload=payload.model_dump(exclude_none=False), + ) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + return _success(data=updated) + + +@router.post("/studio/projects/{project_id}/preview") +def quantum_studio_preview(project_id: str, request: Request) -> Any: + try: + identity = _require_identity(request) + draft_detail = request.app.state.authoring_service.get_draft(project_id) + _ensure_studio_project_owner(identity, draft_detail) + except PermissionError as exc: + return _error(status_code=401 if str(exc) == "missing_bearer_token" else 403, message=str(exc)) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "studio_project_missing") + request.app.state.author_project_graph_service.project_payload(project_id=project_id, draft_detail=draft_detail) + report = request.app.state.authoring_service.run_simulation_for_world_version(project_id) + return _success(data=_studio_preview_payload(project_id, report)) + + +@router.get("/studio/projects/{project_id}/export") +def quantum_studio_export(project_id: str, format: str, request: Request) -> Any: + try: + identity = _require_identity(request) + draft_detail = request.app.state.authoring_service.get_draft(project_id) + _ensure_studio_project_owner(identity, draft_detail) + except PermissionError as exc: + return _error(status_code=401 if str(exc) == "missing_bearer_token" else 403, message=str(exc)) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "studio_project_missing") + normalized = str(format or "").strip().lower() + if normalized not in {"json", "markdown", "pdf"}: + return _error(status_code=400, message="studio_export_format_invalid") + project = request.app.state.author_project_graph_service.project_payload(project_id=project_id, draft_detail=draft_detail) + return _success(data=request.app.state.author_project_graph_service.export_project(project=project, format_value=normalized)) + + +@router.put("/studio/projects/{project_id}/engine") +def quantum_studio_set_engine(project_id: str, payload: QuantumStudioEngineRequest, request: Request) -> Any: + engine = str(payload.engine or "").strip() + if engine not in QUANTUM_STUDIO_ENGINES: + return _error(status_code=400, message="studio_engine_invalid") + try: + identity = _require_identity(request) + draft_detail = request.app.state.authoring_service.get_draft(project_id) + _ensure_studio_project_owner(identity, draft_detail) + except PermissionError as exc: + return _error(status_code=401 if str(exc) == "missing_bearer_token" else 403, message=str(exc)) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "studio_project_missing") + updated = request.app.state.author_project_graph_service.set_engine( + project_id=project_id, + draft_detail=draft_detail, + engine=engine, + ) + return _success(data=updated) + + +@router.put("/studio/projects/{project_id}/world-rules") +def quantum_studio_world_rules(project_id: str, payload: QuantumStudioWorldRulesRequest, request: Request) -> Any: + try: + identity = _require_identity(request) + draft_detail = request.app.state.authoring_service.get_draft(project_id) + _ensure_studio_project_owner(identity, draft_detail) + except PermissionError as exc: + return _error(status_code=401 if str(exc) == "missing_bearer_token" else 403, message=str(exc)) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "studio_project_missing") + worldpack = dict(draft_detail.get("worldpack") or {}) + valid_rule_ids = {item["id"] for item in _studio_world_rule_specs(worldpack)} + requested_rule_ids = [str(item).strip() for item in payload.ruleIds if str(item).strip()] + if any(item not in valid_rule_ids for item in requested_rule_ids): + return _error(status_code=400, message="studio_world_rules_invalid") + updated = request.app.state.author_project_graph_service.set_world_rules( + project_id=project_id, + draft_detail=draft_detail, + rule_ids=sorted(set(requested_rule_ids)), + ) + return _success(data=updated) + + +@router.get("/library/works") +def quantum_library_works(request: Request, filter: str = "recent") -> Dict[str, Any]: + identity = _maybe_identity(request) + account_id = None + if identity is not None: + account_id = str(identity.get("account_id") or identity.get("actor_id") or "").strip() or None + return _success(data=_library_works_payload(request, account_id=account_id, filter_value=filter)) + + +@router.get("/library/stats") +def quantum_library_stats(request: Request) -> Dict[str, Any]: + identity = _maybe_identity(request) + account_id = None + if identity is not None: + account_id = str(identity.get("account_id") or identity.get("actor_id") or "").strip() or None + return _success(data=_library_stats_payload(request, account_id=account_id)) + + +@router.get("/library/achievements") +def quantum_library_achievements(request: Request) -> Dict[str, Any]: + identity = _maybe_identity(request) + account_id = None + if identity is not None: + account_id = str(identity.get("account_id") or identity.get("actor_id") or "").strip() or None + return _success(data=request.app.state.quantum_read_model_service.library_achievements(account_id=account_id)) + + +@router.post("/library/follows") +def quantum_library_follow(payload: QuantumLibraryFollowRequest, request: Request) -> Any: + try: + identity = _require_identity(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + try: + data = request.app.state.quantum_read_model_service.follow_library_target( + account_id=_quantum_identity_account_id(identity), + actor_id=_quantum_identity_actor_id(identity) or None, + target_type=payload.targetType, + target_id=payload.targetId, + ) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + return _success(data=data) + + +@router.delete("/library/follows/{target_type}/{target_id}") +def quantum_library_unfollow(target_type: str, target_id: str, request: Request) -> Any: + try: + identity = _require_identity(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + try: + data = request.app.state.quantum_read_model_service.unfollow_library_target( + account_id=_quantum_identity_account_id(identity), + actor_id=_quantum_identity_actor_id(identity) or None, + target_type=target_type, + target_id=target_id, + ) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + return _success(data=data) + + +@router.post("/library/works/{work_id}/favorite") +def quantum_library_favorite(work_id: str, request: Request) -> Any: + try: + identity = _require_identity(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + account_id = _quantum_identity_account_id(identity) + try: + request.app.state.quantum_read_model_service.favorite_library_work(account_id=account_id, work_id=work_id) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + return _success(data={"workId": work_id, "favorited": True}) + + +@router.delete("/library/works/{work_id}/favorite") +def quantum_library_unfavorite(work_id: str, request: Request) -> Any: + try: + identity = _require_identity(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + account_id = _quantum_identity_account_id(identity) + request.app.state.quantum_read_model_service.unfavorite_library_work(account_id=account_id, work_id=work_id) + return _success(data={"workId": work_id, "favorited": False}) + + +@router.get("/story/import/public-works") +def quantum_story_import_public_works(request: Request) -> Dict[str, Any]: + return _success(data=_story_import_public_works_payload(request)) + + +@router.get("/story/import/recent") +def quantum_story_import_recent(request: Request) -> Dict[str, Any]: + identity = _maybe_identity(request) + account_id = str((identity or {}).get("account_id") or (identity or {}).get("actor_id") or "").strip() or None + return _success(data=_story_import_recent_payload(request, account_id=account_id)) + + +@router.post("/story/import/start") +def quantum_story_import_start(payload: QuantumStoryImportStartRequest, request: Request) -> Any: + target_type = str(payload.targetType or "").strip() + target_id = str(payload.targetId or "").strip() + if not target_type or target_type not in {"world", "session"}: + return _error(status_code=400, message="story_import_target_type_invalid") + if not target_id: + return _error(status_code=400, message="story_import_target_required") + + identity = _maybe_identity(request) + account_id = str((identity or {}).get("account_id") or (identity or {}).get("actor_id") or "").strip() or None + + if target_type == "world": + try: + session_payload = request.app.state.session_service.create_session(target_id, reader_id=account_id) + bootstrap_status = "deferred" + generation_job = None + if not bool(payload.deferBootstrap): + generation_job = _enqueue_reader_generation_job( + request, + operation="story_import_bootstrap", + session_id=session_payload["session_id"], + reader_id=account_id, + account_id=account_id, + ) + bootstrap_status = "queued" + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + return _success( + data={ + "mode": "start", + "sessionId": session_payload["session_id"], + "worldId": session_payload["world_id"], + "worldVersionId": session_payload["world_version_id"], + "bootstrapStatus": bootstrap_status, + "generationJob": _story_generation_job_payload(request, job=generation_job) if generation_job else None, + "handoffUrl": _story_import_handoff_url( + request, + session_id=session_payload["session_id"], + world_id=session_payload["world_id"], + account_id=account_id, + ), + } + ) + + try: + detail = request.app.state.repository.get_session(target_id) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + owner_account_id = str(detail.metadata.get("reader_id") or detail.player_profile.get("reader_id") or "").strip() + if owner_account_id: + if not account_id: + return _error(status_code=401, message="auth_required") + if owner_account_id != account_id: + return _error(status_code=403, message="story_import_session_ownership_mismatch") + return _success( + data={ + "mode": "resume", + "sessionId": detail.session_id, + "worldId": detail.world_id, + "worldVersionId": str(detail.metadata.get("world_version_id") or ""), + "handoffUrl": _story_import_handoff_url( + request, + session_id=detail.session_id, + world_id=detail.world_id, + account_id=account_id, + ), + } + ) + + +@router.get("/story/session/{sessionId}") +def quantum_story_session(sessionId: str, request: Request) -> Any: + try: + bundle = _story_session_bundle(request, session_id=sessionId, limit=1, latest=True) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except PermissionError as exc: + status_code = 401 if str(exc) == "auth_required" else 403 + return _error(status_code=status_code, message=str(exc)) + return _success(data=_story_session_payload(bundle)) + + +@router.get("/media/assets/{assetId}") +def quantum_private_media_asset(assetId: str, expires: int, signature: str, request: Request) -> Response: + try: + image_bytes, mime_type = request.app.state.illustration_service.private_asset_response( + asset_id=assetId, + expires=expires, + signature=signature, + ) + except PermissionError: + return JSONResponse(status_code=403, content={"detail": {"code": "media_asset_signature_invalid"}}) + except KeyError as exc: + return JSONResponse(status_code=404, content={"detail": {"code": "media_asset_missing", "reason": str(exc)}}) + return Response( + content=image_bytes, + media_type=mime_type, + headers={"Cache-Control": "private, max-age=60"}, + ) + + +@router.get("/story/session/{sessionId}/nodes") +def quantum_story_session_nodes( + sessionId: str, + request: Request, + startChapter: Optional[int] = None, + endChapter: Optional[int] = None, + limit: Optional[int] = None, + latest: bool = False, +) -> Any: + try: + bundle = _story_session_bundle( + request, + session_id=sessionId, + start_chapter=startChapter, + end_chapter=endChapter, + limit=limit, + latest=latest, + ) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except PermissionError as exc: + status_code = 401 if str(exc) == "auth_required" else 403 + return _error(status_code=status_code, message=str(exc)) + return _success(data=_story_nodes_payload(bundle)) + + +@router.get("/story/session/{sessionId}/choices") +def quantum_story_session_choices(sessionId: str, nodeId: str, request: Request) -> Any: + try: + bundle = _story_session_bundle(request, session_id=sessionId, limit=1, latest=True) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except PermissionError as exc: + status_code = 401 if str(exc) == "auth_required" else 403 + return _error(status_code=status_code, message=str(exc)) + return _success(data=_story_choices_payload(bundle, node_id=str(nodeId or "").strip())) + + +@router.post("/story/choice") +def quantum_story_choice(payload: QuantumStoryChoiceRequest, request: Request) -> Any: + session_id = str(payload.sessionId or "").strip() + choice_id = str(payload.choiceId or "").strip() + node_id = str(payload.nodeId or "").strip() + if not session_id or not choice_id or not node_id: + return _error(status_code=400, message="story_reader_choice_invalid") + try: + bundle = _story_session_bundle(request, session_id=session_id, limit=1, latest=True) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except PermissionError as exc: + status_code = 401 if str(exc) == "auth_required" else 403 + return _error(status_code=status_code, message=str(exc)) + + choices = _story_choices_payload(bundle, node_id=node_id) + selected_choice = next((item for item in choices if str(item.get("id") or "") == choice_id), None) + if selected_choice is None: + return _error(status_code=400, message="story_reader_choice_invalid") + + try: + bundle = _story_claim_guest_session_for_viewer(request, bundle=bundle) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except PermissionError as exc: + status_code = 401 if str(exc) == "auth_required" else 403 + return _error(status_code=status_code, message=str(exc)) + + reader_id = _story_continue_reader_id(bundle) + access = request.app.state.billing_service.access_check(session_id, reader_id=reader_id, account_id=reader_id) + if access.get("required"): + return _error( + status_code=402, + message="story_reader_payment_required", + data={ + "paywall": _story_paywall_payload(access), + "continuityContract": _story_continuity_contract_payload( + build_reader_continuity_contract( + status="payment_required", + session_id=session_id, + paywall=access, + ) + ), + }, + ) + + try: + story_chapters = list(bundle.get("chapter_rows") or bundle.get("steps") or []) + latest_step = (story_chapters or [None])[-1] + latest_reader_view = _story_get_field(latest_step, "reader_view") if latest_step else None + latest_chapter_index = int(_story_get_field(latest_reader_view, "chapter_index", 0) or 0) + source_chapter_id = str(_story_get_field(latest_step, "chapter_id", "") or f"chapter_{session_id}_{latest_chapter_index}") + request.app.state.repository.save_route_choice( + session_id=session_id, + chapter_id=source_chapter_id, + choice_id=choice_id, + payload_json={ + "choice_id": choice_id, + "node_id": node_id, + "selected_choice": selected_choice, + "source": "quantum_story_choice", + }, + ) + except Exception: + pass + + try: + generation_job = _enqueue_reader_generation_job( + request, + operation="story_choice", + session_id=session_id, + reader_id=reader_id, + account_id=reader_id, + choice_id=choice_id, + freeform_intent=str(selected_choice.get("text") or ""), + ) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + return _success(data={"generationJob": _story_generation_job_payload(request, job=generation_job)}) + + +@router.get("/story/generation-jobs/{jobId}") +def quantum_story_generation_job(jobId: str, request: Request) -> Any: + try: + job = _story_generation_job_access(request, job_id=jobId) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except PermissionError as exc: + status_code = 401 if str(exc) == "auth_required" else 403 + return _error(status_code=status_code, message=str(exc)) + return _success(data=_story_generation_job_payload(request, job=job)) + + +@router.post("/story/generation-jobs/{jobId}/resume") +def quantum_story_generation_job_resume(jobId: str, request: Request) -> Any: + try: + job = _story_generation_job_access(request, job_id=jobId) + job_status = str(job.get("status") or "") + if job_status == "succeeded": + return _success(data=_story_generation_job_payload(request, job=job)) + if job_status == "running" and job.get("lease_status") != "expired": + return _success(data=_story_generation_job_payload(request, job=job)) + identity = _maybe_identity(request) + requested_by = str((identity or {}).get("account_id") or job.get("requested_by") or "reader").strip() + request.app.state.async_job_service.resume_job( + jobId, + requested_by=requested_by, + force=job_status != "queued", + schedule=None, + ) + resumed = request.app.state.async_job_service.run_job(jobId) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except PermissionError as exc: + status_code = 401 if str(exc) == "auth_required" else 403 + return _error(status_code=status_code, message=str(exc)) + except ValueError as exc: + return _error(status_code=409, message=str(exc)) + return _success(data=_story_generation_job_payload(request, job=resumed)) + + +@router.get("/story/session/{sessionId}/deviation") +def quantum_story_session_deviation(sessionId: str, request: Request) -> Any: + try: + bundle = _story_session_bundle(request, session_id=sessionId, limit=1, latest=True) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except PermissionError as exc: + status_code = 401 if str(exc) == "auth_required" else 403 + return _error(status_code=status_code, message=str(exc)) + return _success(data=_story_deviation_payload(bundle)) + + +@router.post("/story/session/{sessionId}/bookmark") +def quantum_story_session_bookmark(sessionId: str, payload: QuantumStoryBookmarkRequest, request: Request) -> Any: + node_id = str(payload.nodeId or "").strip() + if not node_id: + return _error(status_code=400, message="story_reader_bookmark_invalid") + try: + bundle = _story_session_bundle(request, session_id=sessionId) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except PermissionError as exc: + status_code = 401 if str(exc) == "auth_required" else 403 + return _error(status_code=status_code, message=str(exc)) + + account_id = bundle.get("owner_account_id") or bundle.get("viewer_account_id") + if not account_id: + return _error(status_code=401, message="auth_required") + + valid_node_ids = {str(item.get("id") or "") for item in _story_nodes_payload(bundle)} + if node_id not in valid_node_ids: + return _error(status_code=400, message="story_reader_bookmark_invalid") + + bookmark = request.app.state.quantum_read_model_service.bookmark_story_node( + account_id=account_id, + session_id=sessionId, + node_id=node_id, + ) + return _success( + data=_story_bookmark_response_payload( + request, + session_id=sessionId, + account_id=account_id, + node_id=node_id, + saved=True, + bookmark_id=bookmark["bookmark_id"], + ) + ) + + +@router.delete("/story/session/{sessionId}/bookmark") +def quantum_story_session_unbookmark(sessionId: str, nodeId: str, request: Request) -> Any: + node_id = str(nodeId or "").strip() + if not node_id: + return _error(status_code=400, message="story_reader_bookmark_invalid") + try: + bundle = _story_session_bundle(request, session_id=sessionId) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except PermissionError as exc: + status_code = 401 if str(exc) == "auth_required" else 403 + return _error(status_code=status_code, message=str(exc)) + + account_id = bundle.get("owner_account_id") or bundle.get("viewer_account_id") + if not account_id: + return _error(status_code=401, message="auth_required") + + valid_node_ids = {str(item.get("id") or "") for item in _story_nodes_payload(bundle)} + if node_id not in valid_node_ids: + return _error(status_code=400, message="story_reader_bookmark_invalid") + + bookmark = request.app.state.quantum_read_model_service.unbookmark_story_node( + account_id=account_id, + session_id=sessionId, + node_id=node_id, + ) + return _success( + data=_story_bookmark_response_payload( + request, + session_id=sessionId, + account_id=account_id, + node_id=node_id, + saved=False, + bookmark_id=bookmark.get("bookmark_id"), + ) + ) + + +@router.post("/story/session/{sessionId}/share") +def quantum_story_session_share(sessionId: str, request: Request) -> Any: + try: + bundle = _story_session_bundle(request, session_id=sessionId) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except PermissionError as exc: + status_code = 401 if str(exc) == "auth_required" else 403 + return _error(status_code=status_code, message=str(exc)) + account_id = bundle.get("owner_account_id") + identity = bundle.get("identity") + if not account_id or identity is None: + return _error(status_code=401, message="auth_required") + current_node_id = str(_story_session_payload(bundle).get("currentNodeId") or "") + share_row = request.app.state.repository.save_story_session_share_token( + { + "session_id": sessionId, + "account_id": account_id, + "node_id": current_node_id, + "sharer_name": str(identity.get("display_name") or identity.get("actor_id") or account_id), + "status": "active", + "expires_at": (datetime.now(timezone.utc) + timedelta(days=QUANTUM_STORY_SHARE_TTL_DAYS)).isoformat(), + } + ) + request.app.state.analytics_service.track( + "story_share_created", + reader_id=account_id, + account_id=account_id, + session_id=sessionId, + world_id=bundle.get("session", {}).get("world_id"), + world_version_id=bundle.get("world_version_id"), + payload_json={ + "share_token": share_row["share_token"], + "node_id": current_node_id, + }, + ) + return _success( + data={ + "shareToken": share_row["share_token"], + "shareUrl": _story_share_url(str(share_row["share_token"])), + "expiresAt": share_row.get("expires_at"), + "status": share_row.get("status", "active"), + } + ) + + +@router.delete("/story/share/{shareToken}") +def quantum_story_revoke_public_share(shareToken: str, request: Request) -> Any: + try: + identity = _require_identity(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + try: + share_row = request.app.state.repository.get_story_session_share_token(shareToken) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + actor_account_id = str(identity.get("account_id") or identity.get("actor_id") or "").strip() + if actor_account_id != str(share_row.get("account_id") or "").strip(): + return _error(status_code=403, message="story_share_token_ownership_mismatch") + revoked = request.app.state.repository.revoke_story_session_share_token(shareToken) + try: + session = request.app.state.repository.get_session(str(revoked.get("session_id") or "")) + world_id = session.world_id + world_version_id = session.metadata.get("world_version_id") + except KeyError: + world_id = None + world_version_id = None + request.app.state.analytics_service.track( + "story_share_revoked", + reader_id=actor_account_id, + account_id=actor_account_id, + session_id=revoked.get("session_id"), + world_id=world_id, + world_version_id=world_version_id, + payload_json={ + "share_token": revoked["share_token"], + "node_id": revoked.get("node_id"), + }, + ) + return _success( + data={ + "shareToken": revoked["share_token"], + "shareUrl": _story_share_url(str(revoked["share_token"])), + "expiresAt": revoked.get("expires_at"), + "status": revoked.get("status", "revoked"), + "revokedAt": revoked.get("revoked_at"), + } + ) + + +@router.get("/story/share/{shareToken}") +def quantum_story_public_share(shareToken: str, request: Request) -> Any: + try: + share_row = request.app.state.repository.get_story_session_share_token(shareToken) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + inactive_reason = _story_share_token_inactive_reason(share_row) + if inactive_reason: + return _error( + status_code=410, + message="story_share_token_inactive", + data={"reason": inactive_reason}, + ) + bundle = _story_public_share_bundle(request, share_token=shareToken) + return _success(data=_story_share_payload(bundle, share_token_row=share_row)) + + +@router.get("/showcase/works") +def quantum_showcase_works(request: Request, sort: str = "hot", page: int = 1, pageSize: int = 20) -> Dict[str, Any]: + viewer_account_id = _showcase_viewer_account_id(request) + items = _showcase_works_payload(request, sort_value=sort, page=page, page_size=pageSize) + viewer_key = request.app.state.quantum_read_model_service.showcase_viewer_key( + viewer_account_id=viewer_account_id, + request_headers=dict(request.headers), + remote_host=(request.client.host if request.client else None), + ) + for item in items: + try: + version = _resolve_showcase_version(request, str(item.get("id") or "")) + except KeyError: + continue + request.app.state.quantum_read_model_service.track_showcase_view( + world_id=str(version.world_id or ""), + world_version_id=version.world_version_id, + viewer_key=viewer_key, + account_id=viewer_account_id, + event_type="impression", + ) + refreshed = _showcase_works_payload(request, sort_value=sort, page=page, page_size=pageSize) + return _success(data=refreshed) + + +@router.get("/showcase/works/{work_id}") +def quantum_showcase_work_detail(work_id: str, request: Request) -> Any: + try: + version = _resolve_showcase_version(request, work_id) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "showcase_work_missing") + world_id = str(version.world_id or "").strip() + viewer_account_id = _showcase_viewer_account_id(request) + viewer_key = request.app.state.quantum_read_model_service.showcase_viewer_key( + viewer_account_id=viewer_account_id, + request_headers=dict(request.headers), + remote_host=(request.client.host if request.client else None), + ) + request.app.state.quantum_read_model_service.track_showcase_view( + world_id=world_id, + world_version_id=version.world_version_id, + viewer_key=viewer_key, + account_id=viewer_account_id, + event_type="view", + ) + interaction_maps = _showcase_interaction_maps(request, world_ids=[world_id], viewer_account_id=viewer_account_id) + return _success( + data=request.app.state.quantum_read_model_service.showcase_item_from_version( + version_summary={ + "world_version_id": version.world_version_id, + "world_id": world_id, + "updated_at": getattr(version, "updated_at", None) or "", + }, + hot_rank=None, + interaction_maps=interaction_maps, + viewer_account_id=viewer_account_id, + ) + ) + + +@router.get("/showcase/works/{work_id}/comments") +def quantum_showcase_work_comments(work_id: str, request: Request, page: int = 1, pageSize: int = 20) -> Any: + try: + version = _resolve_showcase_version(request, work_id) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "showcase_work_missing") + viewer_account_id = _showcase_viewer_account_id(request) + viewer_key = request.app.state.quantum_read_model_service.showcase_viewer_key( + viewer_account_id=viewer_account_id, + request_headers=dict(request.headers), + remote_host=(request.client.host if request.client else None), + ) + request.app.state.quantum_read_model_service.track_showcase_view( + world_id=str(version.world_id or ""), + world_version_id=version.world_version_id, + viewer_key=viewer_key, + account_id=viewer_account_id, + event_type="view", + ) + page_index = max(1, int(page or 1)) + per_page = max(1, min(100, int(pageSize or 20))) + comments = request.app.state.repository.list_showcase_work_comments( + world_id=version.world_id, + status="published", + limit=per_page, + offset=(page_index - 1) * per_page, + ) + return _success(data=[_showcase_comment_payload(item) for item in comments]) + + +@router.post("/showcase/works/{work_id}/like") +def quantum_showcase_like(work_id: str, request: Request) -> Any: + try: + identity = _require_identity(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + try: + version = _resolve_showcase_version(request, work_id) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "showcase_work_missing") + account_id = str(identity.get("account_id") or identity.get("actor_id") or "").strip() + request.app.state.repository.save_showcase_work_like( + { + "world_id": version.world_id, + "world_version_id": version.world_version_id, + "account_id": account_id, + "actor_id": identity.get("actor_id"), + } + ) + request.app.state.analytics_service.track( + "showcase_work_liked", + reader_id=account_id, + account_id=account_id, + world_id=version.world_id, + world_version_id=version.world_version_id, + payload_json={"work_id": work_id}, + ) + like_count = request.app.state.repository.showcase_work_like_counts(world_ids=[version.world_id]).get(version.world_id, 0) + return _success(data={"likeCount": int(like_count), "viewerHasLiked": True}) + + +@router.delete("/showcase/works/{work_id}/like") +def quantum_showcase_unlike(work_id: str, request: Request) -> Any: + try: + identity = _require_identity(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + try: + version = _resolve_showcase_version(request, work_id) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "showcase_work_missing") + account_id = str(identity.get("account_id") or identity.get("actor_id") or "").strip() + request.app.state.repository.delete_showcase_work_like(world_id=version.world_id, account_id=account_id) + request.app.state.analytics_service.track( + "showcase_work_unliked", + reader_id=account_id, + account_id=account_id, + world_id=version.world_id, + world_version_id=version.world_version_id, + payload_json={"work_id": work_id}, + ) + like_count = request.app.state.repository.showcase_work_like_counts(world_ids=[version.world_id]).get(version.world_id, 0) + return _success(data={"likeCount": int(like_count), "viewerHasLiked": False}) + + +@router.post("/showcase/works/{work_id}/comments") +def quantum_showcase_post_comment(work_id: str, payload: QuantumShowcaseCommentRequest, request: Request) -> Any: + try: + identity = _require_identity(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + try: + version = _resolve_showcase_version(request, work_id) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "showcase_work_missing") + content = str(payload.content or "").strip() + if not content: + return _error(status_code=400, message="showcase_comment_required") + if len(content) > 500: + return _error(status_code=400, message="showcase_comment_too_long") + author_name = str(identity.get("display_name") or identity.get("actor_id") or "用户").strip() or "用户" + comment = request.app.state.repository.save_showcase_work_comment( + { + "world_id": version.world_id, + "world_version_id": version.world_version_id, + "account_id": str(identity.get("account_id") or identity.get("actor_id") or "").strip(), + "actor_id": identity.get("actor_id"), + "author_name": author_name, + "content": content, + "status": "published", + } + ) + request.app.state.analytics_service.track( + "showcase_work_commented", + reader_id=str(identity.get("account_id") or identity.get("actor_id") or "").strip(), + account_id=str(identity.get("account_id") or identity.get("actor_id") or "").strip(), + world_id=version.world_id, + world_version_id=version.world_version_id, + payload_json={"work_id": work_id, "comment_id": comment.get("showcase_comment_id")}, + ) + return _success(data=_showcase_comment_payload(comment)) + + +@router.post("/showcase/works/{work_id}/tip") +def quantum_showcase_tip(work_id: str, payload: QuantumShowcaseTipRequest, request: Request) -> Any: + try: + identity = _require_identity(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + try: + version = _resolve_showcase_version(request, work_id) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "showcase_work_missing") + amount = int(payload.amount or 0) + if amount < 1: + return _error(status_code=400, message="showcase_tip_amount_invalid") + account_id = str(identity.get("account_id") or identity.get("actor_id") or "").strip() + current_balance = float(request.app.state.billing_service.wallet_balance(account_id=account_id, wallet_type="story_credits")) + if current_balance < amount: + return _error(status_code=402, message="insufficient_story_credits") + updated_wallet = request.app.state.billing_service.debit_wallet_credits(account_id=account_id, wallet_type="story_credits", amount=float(amount)) + balance_after = float(updated_wallet.get("balance") or 0.0) + tip = request.app.state.repository.save_showcase_work_tip( + { + "world_id": version.world_id, + "world_version_id": version.world_version_id, + "account_id": account_id, + "actor_id": identity.get("actor_id"), + "amount": amount, + "wallet_type": "story_credits", + "balance_after": balance_after, + } + ) + billing_snapshot = request.app.state.billing_service.subscription_status(account_id=account_id) + access_tier = billing_snapshot.get("effective_tier") or ((billing_snapshot.get("subscription") or {}).get("tier_id")) + analytics_payload = { + "world_id": version.world_id, + "world_version_id": version.world_version_id, + "amount": amount, + "wallet_type": "story_credits", + "balance": balance_after, + } + request.app.state.analytics_service.track( + "showcase_tip_sent", + reader_id=account_id, + account_id=account_id, + world_id=version.world_id, + world_version_id=version.world_version_id, + access_tier=access_tier, + payload_json=analytics_payload, + ) + request.app.state.analytics_service.track( + "story_credits_consumed", + reader_id=account_id, + account_id=account_id, + world_id=version.world_id, + world_version_id=version.world_version_id, + access_tier=access_tier, + payload_json=analytics_payload, + ) + request.app.state.analytics_service.track( + "credits_consumed", + reader_id=account_id, + account_id=account_id, + world_id=version.world_id, + world_version_id=version.world_version_id, + access_tier=access_tier, + payload_json=analytics_payload, + ) + return _success(data={"tipId": tip["showcase_tip_id"], "amount": amount, "balanceAfter": balance_after}) + + +@router.post("/settings/membership/subscribe") +def quantum_membership_subscribe(payload: QuantumMembershipSubscribeRequest, request: Request) -> Any: + try: + identity = _require_identity(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + try: + return _success(data=_start_membership_checkout(request, identity=identity, raw_plan_id=payload.planId)) + except ValueError as exc: + return _quantum_checkout_error(exc) + + +@router.post("/settings/ink/purchase") +def quantum_ink_purchase(payload: QuantumInkPurchaseRequest, request: Request) -> Any: + try: + identity = _require_identity(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + try: + return _success(data=_start_ink_checkout(request, identity=identity, raw_package_id=payload.packageId)) + except ValueError as exc: + reason = str(exc) + status_code = 400 + if reason in {"stripe_not_configured", "stripe_sdk_missing"}: + status_code = 503 + return _error(status_code=status_code, message=reason) + + +@router.post("/settings/ink/{checkout_session_id}/complete") +def quantum_complete_ink_checkout( + checkout_session_id: str, + request: Request, + payload: Optional[QuantumInkCheckoutCompleteRequest] = None, +) -> Any: + try: + identity = _require_identity(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + account_id = str((payload.accountId if payload else None) or identity.get("account_id") or identity.get("actor_id") or "").strip() + try: + completed = request.app.state.billing_service.complete_checkout_session( + checkout_session_id=checkout_session_id, + account_id=account_id, + ) + except PermissionError as exc: + return _error(status_code=403, message=str(exc)) + except KeyError as exc: + return _error(status_code=404, message=str(exc)) + except ValueError as exc: + reason = str(exc) + status_code = 400 + if reason in {"stripe_not_configured", "stripe_sdk_missing"}: + status_code = 503 + return _error(status_code=status_code, message=reason) + wallet = dict(completed.get("wallet") or {}) + processed_event = dict(completed.get("processed_event") or {}) + request.app.state.analytics_service.track( + "story_credits_purchased", + reader_id=account_id, + account_id=account_id, + access_tier="story_credits", + payload_json={ + "checkout_session_id": checkout_session_id, + "package_id": processed_event.get("processing_result", {}).get("package_id"), + "granted_units": processed_event.get("processing_result", {}).get("granted_units"), + "wallet_balance": wallet.get("balance"), + "provider": (completed.get("checkout") or {}).get("provider"), + }, + ) + return _success( + data={ + "checkoutSessionId": checkout_session_id, + "status": str((completed.get("checkout") or {}).get("status") or ""), + "packageId": processed_event.get("processing_result", {}).get("package_id"), + "grantedUnits": processed_event.get("processing_result", {}).get("granted_units"), + "walletBalance": wallet.get("balance"), + } + ) + + +@router.post("/settings/account/password-change") +def quantum_change_account_password(payload: QuantumSettingsAccountPasswordChangeRequest, request: Request, response: Response) -> Any: + try: + identity = _require_identity(request) + token_result = request.app.state.auth_service.change_password( + actor_id=str(identity.get("actor_id") or ""), + current_password=payload.currentPassword, + new_password=payload.newPassword, + ) + response.set_cookie( + value=token_result["token"]["access_token"], + **request.app.state.auth_service.auth_cookie_settings(), + ) + return _success( + data=_frontend_auth_response( + request, + identity=token_result["identity"], + access_token=token_result["token"]["access_token"], + refresh_token=(token_result.get("refresh") or {}).get("refresh_token"), + ) + ) + except AuthServiceError as exc: + detail = exc.detail() + return _error(status_code=exc.http_status, message=str(detail.get("reason") or detail.get("code") or "auth_error"), data=detail) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "auth_identity_missing") + + +@router.get("/settings/account/export") +def quantum_export_account(request: Request, format: str = "json") -> Any: + try: + identity = _require_identity(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + if str(format or "").strip().lower() != "json": + return _error(status_code=400, message="unsupported_export_format") + return _success(data=_auth_export_payload(request, identity=identity)) + + +@router.post("/settings/account/email-change/request") +def quantum_request_email_change(payload: QuantumSettingsAccountEmailChangeRequest, request: Request) -> Any: + try: + identity = _require_identity(request) + result = request.app.state.auth_service.request_email_change( + actor_id=str(identity.get("actor_id") or ""), + current_password=payload.currentPassword, + new_email=payload.newEmail, + ) + return _success( + data={ + "status": result.get("status"), + "pendingEmail": result.get("pending_email_address"), + "delivery": result.get("delivery"), + } + ) + except AuthServiceError as exc: + detail = exc.detail() + return _error(status_code=exc.http_status, message=str(detail.get("reason") or detail.get("code") or "auth_error"), data=detail) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + reason = str(exc) + status_code = 409 if reason in {"email_change_email_already_in_use", "email_change_email_pending_elsewhere"} else 400 + return _error(status_code=status_code, message=reason) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "auth_identity_missing") + + +@router.post("/settings/account/email-change/confirm") +def quantum_confirm_email_change(payload: QuantumSettingsAccountEmailConfirmRequest, request: Request) -> Any: + try: + result = request.app.state.auth_service.confirm_email_change(token=payload.token) + return _success( + data={ + "status": result.get("status"), + "identity": _frontend_user(request, result.get("identity") or {}), + } + ) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + reason = str(exc) + status_code = 409 if reason == "email_change_email_already_in_use" else 400 + return _error(status_code=status_code, message=reason) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "auth_identity_missing") + + +@router.post("/settings/account/deactivate") +def quantum_deactivate_account(request: Request, response: Response) -> Any: + try: + identity = _require_identity(request) + result = request.app.state.auth_service.deactivate_account( + actor_id=str(identity.get("actor_id") or ""), + requested_by=str(identity.get("actor_id") or ""), + ) + response.delete_cookie( + key=request.app.state.auth_service.auth_cookie_settings()["key"], + path="/", + domain=request.app.state.auth_service.auth_cookie_settings().get("domain"), + ) + return _success(data={"status": result.get("status"), "revokedSessionCount": len(result.get("revoked_sessions") or [])}) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) + except KeyError as exc: + return _error(status_code=404, message=str(exc) or "auth_identity_missing") + + +@router.post("/payments/subscription-session") +def quantum_legacy_subscription_session(payload: QuantumLegacySubscriptionSessionRequest, request: Request) -> Any: + try: + identity = _require_identity(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + try: + return _start_membership_checkout(request, identity=identity, raw_plan_id=payload.tierId) + except ValueError as exc: + return _quantum_checkout_error(exc) + + +@router.post("/payments/checkout-session") +def quantum_legacy_checkout_session(payload: QuantumLegacyCheckoutSessionRequest, request: Request) -> Any: + try: + identity = _require_identity(request) + except PermissionError as exc: + return _error(status_code=401, message=str(exc)) + try: + return _start_ink_checkout(request, identity=identity, raw_package_id=payload.packageId) + except ValueError as exc: + return _error(status_code=400, message=str(exc)) diff --git a/src/narrativeos/api/reader.py b/src/narrativeos/api/reader.py index a036d30..20c5f03 100644 --- a/src/narrativeos/api/reader.py +++ b/src/narrativeos/api/reader.py @@ -5,19 +5,23 @@ from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel -from ..services.sessions import ReaderContinueCommand +from .reader_access import ensure_reader_session_access, reader_identity +from ..services.reader_generation_jobs import READER_GENERATION_JOB_TYPE +from ..services.sessions import build_reader_continuity_contract class CreateReaderSessionRequest(BaseModel): world_id: str account_id: Optional[str] = None reader_id: Optional[str] = None + longform_setup: Optional[Dict[str, Any]] = None class ContinueReaderRequest(BaseModel): session_id: str choice_id: Optional[str] = None freeform_intent: Optional[str] = None + steering_directive: Optional[Dict[str, Any]] = None account_id: Optional[str] = None reader_id: Optional[str] = None @@ -37,7 +41,7 @@ class StartCheckoutRequest(BaseModel): account_id: Optional[str] = None reader_id: Optional[str] = None tier_id: str - provider: str = "web_stub" + provider: Optional[str] = None class CheckoutWebhookRequest(BaseModel): @@ -51,9 +55,138 @@ class CheckoutWebhookRequest(BaseModel): occurred_at: Optional[str] = None +class PortalSessionRequest(BaseModel): + return_url: Optional[str] = None + + +class CompleteCheckoutSessionRequest(BaseModel): + account_id: Optional[str] = None + reader_id: Optional[str] = None + + +class AppleMobilePurchaseVerifyRequest(BaseModel): + account_id: Optional[str] = None + reader_id: Optional[str] = None + original_transaction_id: Optional[str] = None + signed_transaction_info: Optional[str] = None + tier_id: Optional[str] = None + environment: Optional[str] = None + + +class GoogleMobilePurchaseVerifyRequest(BaseModel): + account_id: Optional[str] = None + reader_id: Optional[str] = None + purchase_token: str + subscription_id: Optional[str] = None + package_name: Optional[str] = None + tier_id: Optional[str] = None + environment: Optional[str] = None + + +class MobilePurchaseRestoreRequest(BaseModel): + account_id: Optional[str] = None + reader_id: Optional[str] = None + apple_original_transaction_ids: list[str] = [] + apple_tier_id: Optional[str] = None + apple_environment: Optional[str] = None + google_purchase_tokens: list[str] = [] + google_subscription_id: Optional[str] = None + google_tier_id: Optional[str] = None + google_environment: Optional[str] = None + package_name: Optional[str] = None + + +class AppleServerNotificationRequest(BaseModel): + account_id: Optional[str] = None + signedPayload: Optional[str] = None + notificationType: Optional[str] = None + data: Dict[str, Any] = {} + + +class GoogleRTDNRequest(BaseModel): + account_id: Optional[str] = None + packageName: Optional[str] = None + message: Dict[str, Any] = {} + subscriptionNotification: Dict[str, Any] = {} + + +class QualityFeedbackRequest(BaseModel): + trace_id: str + feedback: str + reason_code: Optional[str] = None + note: Optional[str] = None + quality_event_id: Optional[str] = None + + router = APIRouter(prefix="/v1/reader", tags=["reader"]) +def _reader_identity(request: Request) -> Optional[Dict[str, Any]]: + return reader_identity(request) + + +def _resolve_reader_account_id( + request: Request, + *, + account_id: Optional[str] = None, + reader_id: Optional[str] = None, +) -> str: + identity = _reader_identity(request) + if identity: + token_account_id = str(identity.get("account_id") or identity.get("actor_id") or "") + provided = next((str(value) for value in [account_id, reader_id] if str(value or "").strip()), None) + if provided and token_account_id and provided != token_account_id: + raise HTTPException( + status_code=403, + detail={ + "code": "reader_account_ownership_mismatch", + "provided_account_id": provided, + "token_account_id": token_account_id, + }, + ) + return token_account_id + return request.app.state.billing_service.resolve_account_id(account_id=account_id, reader_id=reader_id) + + +def _reader_generation_scheduler(request: Request): + return getattr(request.app.state, "reader_generation_job_scheduler", None) + + +def _reader_generation_job_payload(job: Dict[str, Any]) -> Dict[str, Any]: + payload = dict(job.get("payload") or {}) + result_summary = dict(job.get("result_summary") or {}) + return { + "jobId": job.get("job_id"), + "jobType": job.get("job_type"), + "operation": payload.get("operation") or result_summary.get("operation"), + "status": job.get("status"), + "sessionId": payload.get("session_id") or result_summary.get("session_id"), + "readerStatus": result_summary.get("reader_status"), + "pollAfterMs": 1000 if job.get("status") in {"queued", "running"} else 0, + "retryable": job.get("status") in {"queued", "failed"} or job.get("lease_status") == "expired", + "result": result_summary if job.get("status") == "succeeded" else None, + "error": job.get("error"), + "createdAt": job.get("created_at"), + "updatedAt": job.get("updated_at"), + "startedAt": job.get("started_at"), + "finishedAt": job.get("finished_at"), + "attemptCount": job.get("attempt_count", 0), + } + + +def _ensure_reader_generation_job_access(request: Request, job_id: str) -> Dict[str, Any]: + try: + job = request.app.state.async_job_service.get_job(job_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + if str(job.get("job_type") or "") != READER_GENERATION_JOB_TYPE: + raise HTTPException(status_code=404, detail=f"unknown_reader_generation_job:{job_id}") + session_id = str((job.get("payload") or {}).get("session_id") or (job.get("result_summary") or {}).get("session_id") or "") + if session_id: + ensure_reader_session_access(request, session_id=session_id) + return job + + @router.get("/library/worlds") def library_worlds(request: Request) -> Dict[str, Any]: return {"worlds": request.app.state.repository.list_worlds()} @@ -82,7 +215,8 @@ def create_reader_session(payload: CreateReaderSessionRequest, request: Request) try: return request.app.state.session_service.create_session( payload.world_id, - reader_id=payload.reader_id or payload.account_id, + reader_id=_resolve_reader_account_id(request, account_id=payload.account_id, reader_id=payload.reader_id), + longform_setup=payload.longform_setup, ) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) @@ -91,18 +225,68 @@ def create_reader_session(payload: CreateReaderSessionRequest, request: Request) @router.post("/continue") def continue_reader_story(payload: ContinueReaderRequest, request: Request) -> Dict[str, Any]: try: - return request.app.state.session_service.continue_story( - ReaderContinueCommand( - session_id=payload.session_id, - choice_id=payload.choice_id, - freeform_intent=payload.freeform_intent, - ), - reader_id=payload.reader_id or payload.account_id, + ensure_reader_session_access(request, session_id=payload.session_id) + reader_id = _resolve_reader_account_id(request, account_id=payload.account_id, reader_id=payload.reader_id) + access = request.app.state.billing_service.access_check(payload.session_id, reader_id=reader_id, account_id=reader_id) + if access.get("required"): + return { + "session_id": payload.session_id, + "status": "payment_required", + "paywall": access, + "continuity_contract": build_reader_continuity_contract( + status="payment_required", + session_id=payload.session_id, + paywall=access, + ), + } + job = request.app.state.async_job_service.enqueue_job( + job_type=READER_GENERATION_JOB_TYPE, + payload={ + "operation": "reader_continue", + "session_id": payload.session_id, + "choice_id": payload.choice_id, + "freeform_intent": payload.freeform_intent, + "steering_directive": payload.steering_directive, + "reader_id": reader_id, + "account_id": reader_id, + }, + requested_by=reader_id or "reader_guest", + account_id=reader_id, + schedule=_reader_generation_scheduler(request), ) + return {"status": "queued", "job": _reader_generation_job_payload(job)} except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) +@router.get("/jobs/{job_id}") +def reader_generation_job_status(job_id: str, request: Request) -> Dict[str, Any]: + job = _ensure_reader_generation_job_access(request, job_id) + return {"job": _reader_generation_job_payload(job)} + + +@router.post("/jobs/{job_id}/resume") +def reader_generation_job_resume(job_id: str, request: Request) -> Dict[str, Any]: + job = _ensure_reader_generation_job_access(request, job_id) + job_status = str(job.get("status") or "") + if job_status == "succeeded": + return {"job": _reader_generation_job_payload(job)} + if job_status == "running" and job.get("lease_status") != "expired": + return {"job": _reader_generation_job_payload(job)} + requested_by = str((reader_identity(request) or {}).get("account_id") or job.get("requested_by") or "reader") + try: + request.app.state.async_job_service.resume_job( + job_id, + requested_by=requested_by, + force=job_status != "queued", + schedule=None, + ) + resumed = request.app.state.async_job_service.run_job(job_id) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) + return {"job": _reader_generation_job_payload(resumed)} + + @router.get("/entitlements") def reader_entitlements( reader_id: Optional[str] = None, @@ -110,7 +294,7 @@ def reader_entitlements( world_id: Optional[str] = None, request: Request = None, ) -> Dict[str, Any]: - resolved_account_id = request.app.state.billing_service.resolve_account_id(account_id=account_id, reader_id=reader_id) + resolved_account_id = _resolve_reader_account_id(request, account_id=account_id, reader_id=reader_id) return request.app.state.billing_service.list_entitlements_for_account(resolved_account_id, world_id=world_id) @@ -120,7 +304,7 @@ def reader_subscription( account_id: Optional[str] = None, request: Request = None, ) -> Dict[str, Any]: - resolved_account_id = request.app.state.billing_service.resolve_account_id(account_id=account_id, reader_id=reader_id) + resolved_account_id = _resolve_reader_account_id(request, account_id=account_id, reader_id=reader_id) return request.app.state.billing_service.subscription_status(account_id=resolved_account_id) @@ -160,7 +344,8 @@ def grant_reader_entitlement(payload: GrantEntitlementRequest, request: Request) @router.post("/checkout/start") def start_checkout(payload: StartCheckoutRequest, request: Request) -> Dict[str, Any]: - resolved_account_id = request.app.state.billing_service.resolve_account_id( + resolved_account_id = _resolve_reader_account_id( + request, account_id=payload.account_id, reader_id=payload.reader_id, ) @@ -169,10 +354,18 @@ def start_checkout(payload: StartCheckoutRequest, request: Request) -> Dict[str, account_id=resolved_account_id, tier_id=payload.tier_id, provider=payload.provider, + customer_email=(payload.reader_id if payload.reader_id and "@" in payload.reader_id else None), + metadata={"reader_id": payload.reader_id or resolved_account_id}, ) except ValueError as exc: if str(exc) == "checkout_restricted": raise HTTPException(status_code=403, detail={"code": "checkout_restricted", "account_id": resolved_account_id}) + if str(exc) == "checkout_invite_required": + raise HTTPException(status_code=403, detail={"code": "checkout_invite_required", "account_id": resolved_account_id}) + if str(exc) == "email_verification_required_for_billing": + raise HTTPException(status_code=403, detail={"code": "email_verification_required_for_billing", "account_id": resolved_account_id}) + if str(exc) in {"stripe_not_configured", "stripe_sdk_missing"}: + raise HTTPException(status_code=503, detail={"code": str(exc), "account_id": resolved_account_id}) raise HTTPException(status_code=400, detail=str(exc)) request.app.state.analytics_service.track( "checkout_started", @@ -184,6 +377,220 @@ def start_checkout(payload: StartCheckoutRequest, request: Request) -> Dict[str, return {"checkout": checkout} +@router.post("/checkout/{checkout_session_id}/complete") +def complete_checkout_session( + checkout_session_id: str, + payload: CompleteCheckoutSessionRequest, + request: Request, +) -> Dict[str, Any]: + resolved_account_id = _resolve_reader_account_id( + request, + account_id=payload.account_id, + reader_id=payload.reader_id, + ) + try: + completed = request.app.state.billing_service.complete_checkout_session( + checkout_session_id=checkout_session_id, + account_id=resolved_account_id or None, + ) + except PermissionError as exc: + raise HTTPException(status_code=403, detail={"code": str(exc)}) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": str(exc)}) from exc + except ValueError as exc: + reason = str(exc) + status_code = 400 + if reason in {"stripe_not_configured", "stripe_sdk_missing"}: + status_code = 503 + raise HTTPException(status_code=status_code, detail={"code": reason}) from exc + request.app.state.analytics_service.track( + "checkout_completion_reconciled", + reader_id=completed.get("account_id"), + account_id=completed.get("account_id"), + access_tier=(completed.get("subscription") or {}).get("tier_id") or (completed.get("checkout") or {}).get("tier_id"), + payload_json={ + "checkout_session_id": checkout_session_id, + "customer_id": completed.get("customer_id"), + "remote_checkout_status": completed.get("remote_checkout_status"), + "subscription_id": (completed.get("subscription") or {}).get("subscription_id"), + }, + ) + return completed + + +@router.post("/subscription/{account_id}/portal") +def reader_customer_portal(account_id: str, payload: PortalSessionRequest, request: Request) -> Dict[str, Any]: + resolved_account_id = _resolve_reader_account_id(request, account_id=account_id, reader_id=account_id) + try: + portal = request.app.state.billing_service.start_customer_portal( + account_id=resolved_account_id, + return_url=payload.return_url, + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail={"code": "customer_portal_unavailable", "reason": str(exc)}) from exc + except ValueError as exc: + reason = str(exc) + status_code = 400 + if reason in {"stripe_not_configured", "stripe_sdk_missing"}: + status_code = 503 + raise HTTPException(status_code=status_code, detail={"code": reason}) from exc + return {"portal": portal} + + +@router.post("/checkout/stripe-webhook") +async def reader_stripe_checkout_webhook(request: Request) -> Dict[str, Any]: + signature = request.headers.get("Stripe-Signature") or "" + raw_body = await request.body() + try: + processed = request.app.state.billing_service.ingest_stripe_webhook(raw_body=raw_body, signature=signature) + except ValueError as exc: + reason = str(exc) + status_code = 400 + if reason in {"stripe_webhook_not_configured", "stripe_sdk_missing"}: + status_code = 503 + raise HTTPException(status_code=status_code, detail={"code": reason}) from exc + payload = dict(processed.get("event") or {}) + request.app.state.analytics_service.track( + "billing_lifecycle_event_processed", + reader_id=payload.get("account_id"), + account_id=payload.get("account_id"), + payload_json={ + "event_id": payload.get("event_id"), + "event_type": payload.get("event_type"), + "provider": payload.get("provider"), + "status": payload.get("status"), + "stripe_event_type": processed.get("stripe_event_type"), + }, + ) + return processed + + +@router.post("/mobile-purchases/apple/verify") +def verify_apple_mobile_purchase(payload: AppleMobilePurchaseVerifyRequest, request: Request) -> Dict[str, Any]: + resolved_account_id = _resolve_reader_account_id(request, account_id=payload.account_id, reader_id=payload.reader_id) + try: + verified = request.app.state.billing_service.verify_mobile_purchase( + provider="app_store", + account_id=resolved_account_id, + payload=payload.model_dump(), + ) + except ValueError as exc: + reason = str(exc) + status_code = 400 + if reason in {"email_verification_required_for_billing"}: + status_code = 403 + if reason in {"app_store_not_configured", "unsupported_mobile_purchase_provider"}: + status_code = 503 + raise HTTPException(status_code=status_code, detail={"code": reason}) from exc + request.app.state.analytics_service.track( + "mobile_purchase_verified", + reader_id=resolved_account_id, + account_id=resolved_account_id, + access_tier=(verified.get("effective_subscription") or {}).get("tier_id"), + payload_json={"provider": "app_store", **verified}, + ) + return verified + + +@router.post("/mobile-purchases/google/verify") +def verify_google_mobile_purchase(payload: GoogleMobilePurchaseVerifyRequest, request: Request) -> Dict[str, Any]: + resolved_account_id = _resolve_reader_account_id(request, account_id=payload.account_id, reader_id=payload.reader_id) + try: + verified = request.app.state.billing_service.verify_mobile_purchase( + provider="google_play", + account_id=resolved_account_id, + payload=payload.model_dump(), + ) + except ValueError as exc: + reason = str(exc) + status_code = 400 + if reason in {"email_verification_required_for_billing"}: + status_code = 403 + if reason in {"google_play_not_configured", "unsupported_mobile_purchase_provider"}: + status_code = 503 + raise HTTPException(status_code=status_code, detail={"code": reason}) from exc + request.app.state.analytics_service.track( + "mobile_purchase_verified", + reader_id=resolved_account_id, + account_id=resolved_account_id, + access_tier=(verified.get("effective_subscription") or {}).get("tier_id"), + payload_json={"provider": "google_play", **verified}, + ) + return verified + + +@router.post("/mobile-purchases/restore") +def restore_mobile_purchases(payload: MobilePurchaseRestoreRequest, request: Request) -> Dict[str, Any]: + resolved_account_id = _resolve_reader_account_id(request, account_id=payload.account_id, reader_id=payload.reader_id) + try: + restored = request.app.state.billing_service.restore_mobile_purchases( + account_id=resolved_account_id, + payload=payload.model_dump(), + ) + except ValueError as exc: + reason = str(exc) + status_code = 400 + if reason in {"email_verification_required_for_billing"}: + status_code = 403 + if reason in {"app_store_not_configured", "google_play_not_configured"}: + status_code = 503 + raise HTTPException(status_code=status_code, detail={"code": reason}) from exc + request.app.state.analytics_service.track( + "mobile_purchase_restore", + reader_id=resolved_account_id, + account_id=resolved_account_id, + access_tier=restored.get("effective_tier"), + payload_json=restored, + ) + return restored + + +@router.post("/billing/apple-server-notifications") +def apple_server_notifications(payload: AppleServerNotificationRequest, request: Request) -> Dict[str, Any]: + try: + processed = request.app.state.billing_service.ingest_store_notification( + provider="app_store", + payload=payload.model_dump(), + ) + except ValueError as exc: + reason = str(exc) + status_code = 400 + if reason in {"app_store_not_configured"}: + status_code = 503 + raise HTTPException(status_code=status_code, detail={"code": reason}) from exc + request.app.state.analytics_service.track( + "store_notification_processed", + reader_id=processed.get("account_id"), + account_id=processed.get("account_id"), + access_tier=(processed.get("effective_subscription") or {}).get("tier_id"), + payload_json={"provider": "app_store", **processed}, + ) + return processed + + +@router.post("/billing/google-rtdn") +def google_rtdn(payload: GoogleRTDNRequest, request: Request) -> Dict[str, Any]: + try: + processed = request.app.state.billing_service.ingest_store_notification( + provider="google_play", + payload=payload.model_dump(), + ) + except ValueError as exc: + reason = str(exc) + status_code = 400 + if reason in {"google_play_not_configured"}: + status_code = 503 + raise HTTPException(status_code=status_code, detail={"code": reason}) from exc + request.app.state.analytics_service.track( + "store_notification_processed", + reader_id=processed.get("account_id"), + account_id=processed.get("account_id"), + access_tier=(processed.get("effective_subscription") or {}).get("tier_id"), + payload_json={"provider": "google_play", **processed}, + ) + return processed + + @router.post("/checkout/webhook") def reader_checkout_webhook(payload: CheckoutWebhookRequest, request: Request) -> Dict[str, Any]: try: @@ -208,8 +615,9 @@ def reader_checkout_webhook(payload: CheckoutWebhookRequest, request: Request) - @router.post("/subscription/{account_id}/retry-payment") def reader_retry_subscription_payment(account_id: str, request: Request) -> Dict[str, Any]: + resolved_account_id = _resolve_reader_account_id(request, account_id=account_id, reader_id=account_id) try: - payload = request.app.state.billing_service.retry_subscription_payment(account_id=account_id) + payload = request.app.state.billing_service.retry_subscription_payment(account_id=resolved_account_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) request.app.state.analytics_service.track( @@ -218,13 +626,62 @@ def reader_retry_subscription_payment(account_id: str, request: Request) -> Dict account_id=account_id, payload_json=payload, ) + try: + request.app.state.repository.save_quality_feedback_item( + { + "feedback_type": "subscription_retry_requested", + "signal": "retry", + "source_surface": "reader", + "account_id": resolved_account_id, + "world_version_id": None, + "session_id": None, + "chapter_id": None, + "source_ref": {"kind": "account", "account_id": resolved_account_id}, + "payload": payload, + } + ) + except Exception: + pass return payload +@router.post("/quality-feedback") +def submit_reader_quality_feedback(payload: QualityFeedbackRequest, request: Request) -> Dict[str, Any]: + resolved_account_id = _resolve_reader_account_id(request, account_id=None, reader_id=None) + signal = "explicit_positive" if payload.feedback == "thumbs_up" else "explicit_negative" + item = request.app.state.repository.save_quality_feedback_item( + { + "trace_id": payload.trace_id, + "source_event_id": payload.quality_event_id, + "feedback_type": "explicit_user_feedback", + "signal": signal, + "source_surface": "reader", + "account_id": resolved_account_id, + "world_version_id": None, + "session_id": None, + "chapter_id": None, + "source_ref": {"kind": "trace", "trace_id": payload.trace_id, "account_id": resolved_account_id}, + "payload": { + "feedback": payload.feedback, + "reason_code": payload.reason_code, + "note": payload.note, + }, + } + ) + request.app.state.analytics_service.track( + "reader_quality_feedback_submitted", + reader_id=resolved_account_id, + account_id=resolved_account_id, + payload_json=item, + ) + return {"quality_feedback": item} + + @router.post("/subscription/{account_id}/renew") def reader_renew_subscription(account_id: str, request: Request) -> Dict[str, Any]: + resolved_account_id = _resolve_reader_account_id(request, account_id=account_id, reader_id=account_id) try: - payload = request.app.state.billing_service.renew_subscription(account_id=account_id) + payload = request.app.state.billing_service.renew_subscription(account_id=resolved_account_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) request.app.state.analytics_service.track( @@ -238,8 +695,9 @@ def reader_renew_subscription(account_id: str, request: Request) -> Dict[str, An @router.post("/subscription/{account_id}/cancel") def reader_cancel_subscription(account_id: str, request: Request) -> Dict[str, Any]: + resolved_account_id = _resolve_reader_account_id(request, account_id=account_id, reader_id=account_id) try: - payload = request.app.state.billing_service.cancel_subscription(account_id=account_id) + payload = request.app.state.billing_service.cancel_subscription(account_id=resolved_account_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) request.app.state.analytics_service.track( @@ -252,9 +710,23 @@ def reader_cancel_subscription(account_id: str, request: Request) -> Dict[str, A @router.get("/sessions/{session_id}/replay") -def reader_replay(session_id: str, request: Request) -> Dict[str, Any]: +def reader_replay( + session_id: str, + request: Request, + start_chapter: Optional[int] = None, + end_chapter: Optional[int] = None, + limit: Optional[int] = None, + latest: bool = False, +) -> Dict[str, Any]: try: - return request.app.state.repository.get_replay(session_id) + ensure_reader_session_access(request, session_id=session_id) + return request.app.state.repository.get_replay( + session_id, + start_chapter=start_chapter, + end_chapter=end_chapter, + limit=limit, + latest=latest, + ) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) @@ -262,7 +734,7 @@ def reader_replay(session_id: str, request: Request) -> Dict[str, Any]: @router.get("/sessions/{session_id}/prefill") def reader_prefill(session_id: str, request: Request) -> Dict[str, Any]: try: - session_record = request.app.state.repository.get_session(session_id) + session_record = ensure_reader_session_access(request, session_id=session_id) latest_step = request.app.state.repository.get_latest_step(session_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) @@ -272,6 +744,7 @@ def reader_prefill(session_id: str, request: Request) -> Dict[str, Any]: @router.get("/sessions/{session_id}/quote") def reader_quote(session_id: str, request: Request) -> Dict[str, Any]: try: + ensure_reader_session_access(request, session_id=session_id) return request.app.state.billing_service.quote_continue(session_id, "continue") except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) diff --git a/src/narrativeos/api/reader_access.py b/src/narrativeos/api/reader_access.py new file mode 100644 index 0000000..6abd002 --- /dev/null +++ b/src/narrativeos/api/reader_access.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + +from fastapi import HTTPException, Request + + +def reader_identity(request: Request) -> Optional[Dict[str, Any]]: + raw_token = request.app.state.auth_service.extract_request_token( + authorization=request.headers.get("Authorization"), + cookies=request.cookies, + ) + if not raw_token: + return None + try: + return request.app.state.auth_service.resolve_bearer_token(raw_token) + except (PermissionError, KeyError) as exc: + raise HTTPException(status_code=401, detail={"code": "reader_auth_invalid", "reason": str(exc)}) from exc + + +def reader_identity_account_id(identity: Optional[Dict[str, Any]]) -> Optional[str]: + if identity is None: + return None + value = str(identity.get("account_id") or identity.get("actor_id") or "").strip() + return value or None + + +def reader_session_owner_account_id(session_record: Any) -> Optional[str]: + value = str( + (getattr(session_record, "metadata", {}) or {}).get("reader_id") + or (getattr(session_record, "metadata", {}) or {}).get("account_id") + or (getattr(session_record, "player_profile", {}) or {}).get("reader_id") + or "" + ).strip() + return value or None + + +def _registered_reader_owner_exists(request: Request, owner_account_id: str) -> bool: + try: + return request.app.state.repository.get_auth_identity_by_account_id(owner_account_id, default=None) is not None + except AttributeError: + return False + + +def ensure_reader_session_access( + request: Request, + *, + session_id: str, + session_record: Any = None, +) -> Any: + resolved_session = session_record or request.app.state.repository.get_session(session_id) + owner_account_id = reader_session_owner_account_id(resolved_session) + identity = reader_identity(request) + viewer_account_id = reader_identity_account_id(identity) + if not owner_account_id: + return resolved_session + if identity is None and not _registered_reader_owner_exists(request, owner_account_id): + return resolved_session + if not viewer_account_id: + raise HTTPException(status_code=401, detail={"code": "reader_session_auth_required", "session_id": session_id}) + if viewer_account_id != owner_account_id: + raise HTTPException( + status_code=403, + detail={ + "code": "reader_session_ownership_mismatch", + "session_id": session_id, + "token_account_id": viewer_account_id, + }, + ) + return resolved_session 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..293e170 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,133 +753,1550 @@ 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") + + +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.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 and float(current_world.get("avg_exposition_ratio", 0.0)) <= 0.5 @@ -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..86c2a19 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,101 @@ 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, + 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, + 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 +1788,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 +1815,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 +1844,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 +2076,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 +2476,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/canon.py b/src/narrativeos/canon.py index 82135a1..d6456ab 100644 --- a/src/narrativeos/canon.py +++ b/src/narrativeos/canon.py @@ -30,6 +30,34 @@ def _ending_gate_for_event(state: NarrativeState, event: EventAtom) -> EndingGat return EndingGate.from_dict(gate_data) +def _hard_constraint_context( + state: NarrativeState, + world: Optional[WorldBible], +) -> dict: + return { + "facts": set(state.world_facts), + "state_character_ids": set(state.characters.keys()), + "world_character_ids": { + ( + str(item) + if isinstance(item, str) + else str(getattr(item, "character_id", "") or "") + ) + for item in list(world.characters or []) + if ( + (isinstance(item, str) and str(item)) + or getattr(item, "character_id", None) + ) + } if world is not None else set(), + "ceiling": effective_rating_ceiling(state, world=world), + "existing_promise_ids": _promise_ids(state.open_promises), + "closed_promise_ids": set(state.metadata.get("closed_promise_ids", [])), + "recent_scene_window": [normalize_scene_function(scene_function) for scene_function in state.recent_scene_functions[-2:]], + "scene_history": set(state.metadata.get("scene_history", [])), + "forbidden_moves": list(world.forbidden_moves) if world is not None else [], + } + + def _matches_forbidden_move(event: EventAtom, forbidden_move: str) -> bool: normalized_move = forbidden_move.lower() normalized_event = _normalize_text( @@ -59,9 +87,11 @@ def hard_constraint_errors( state: NarrativeState, event: EventAtom, world: Optional[WorldBible] = None, + context: Optional[dict] = None, ) -> List[str]: errors: List[str] = [] - facts = set(state.world_facts) + context = context or _hard_constraint_context(state, world) + facts = set(context.get("facts", set())) missing = [fact for fact in event.preconditions_all if fact not in facts] if missing: @@ -71,16 +101,18 @@ def hard_constraint_errors( if violated: errors.append(f"forbidden_facts_present:{','.join(sorted(violated))}") - missing_characters = [actor for actor in event.actors if actor not in state.characters] + state_character_ids = set(context.get("state_character_ids", set())) + missing_characters = [actor for actor in event.actors if actor not in state_character_ids] if missing_characters: errors.append(f"missing_characters:{','.join(sorted(missing_characters))}") if world is not None: - world_missing = [actor for actor in event.actors if actor not in world.characters] + world_character_ids = set(context.get("world_character_ids", set())) + world_missing = [actor for actor in event.actors if actor not in world_character_ids] if world_missing: errors.append(f"actors_not_in_world:{','.join(sorted(world_missing))}") - ceiling = effective_rating_ceiling(state, world=world) + ceiling = str(context.get("ceiling") or effective_rating_ceiling(state, world=world)) if not rating_allowed(ceiling, event.rating_ceiling): errors.append(f"rating_exceeds_ceiling:{event.rating_ceiling}>{ceiling}") @@ -99,14 +131,14 @@ def hard_constraint_errors( if delta.character not in state.characters: errors.append(f"emotion_delta_unknown_character:{delta.character}") - existing_promise_ids = _promise_ids(state.open_promises) + existing_promise_ids = set(context.get("existing_promise_ids", set())) opened_promise_ids = _promise_ids(event.promises_open) unknown_closed_promises = sorted( promise_id for promise_id in event.promises_close if promise_id not in existing_promise_ids and promise_id not in opened_promise_ids - and promise_id not in set(state.metadata.get("closed_promise_ids", [])) + and promise_id not in set(context.get("closed_promise_ids", set())) ) if unknown_closed_promises: errors.append( @@ -119,15 +151,16 @@ def hard_constraint_errors( f"duplicate_open_promises:{','.join(duplicate_opened_promises)}" ) - if len(state.recent_scene_functions) >= 2: - window = [normalize_scene_function(scene_function) for scene_function in state.recent_scene_functions[-2:]] + recent_scene_window = list(context.get("recent_scene_window", [])) + if len(recent_scene_window) >= 2: + window = recent_scene_window if all(scene_function == normalize_scene_function(event.scene_function) for scene_function in window): errors.append(f"scene_function_window_repeat:{event.scene_function}") if world is not None: forbidden_hits = [ forbidden_move - for forbidden_move in world.forbidden_moves + for forbidden_move in list(context.get("forbidden_moves", [])) if _matches_forbidden_move(event, forbidden_move) ] if forbidden_hits: @@ -137,7 +170,7 @@ def hard_constraint_errors( gate = _ending_gate_for_event(state, event) if state.chapter_index < max(6, gate.min_turn): errors.append(f"ending_gate_min_turn:{state.chapter_index}<{max(6, gate.min_turn)}") - scene_history = set(state.metadata.get("scene_history", [])) + scene_history = set(context.get("scene_history", set())) missing_scene_functions = [ scene_function for scene_function in gate.required_scene_functions @@ -147,7 +180,7 @@ def hard_constraint_errors( errors.append( "ending_gate_missing_scene_functions:%s" % ",".join(sorted(missing_scene_functions)) ) - closed_promise_ids = set(state.metadata.get("closed_promise_ids", [])) + closed_promise_ids = set(context.get("closed_promise_ids", set())) missing_promises = [ promise_id for promise_id in gate.required_closed_promises diff --git a/src/narrativeos/commercialization/__init__.py b/src/narrativeos/commercialization/__init__.py new file mode 100644 index 0000000..221d1a5 --- /dev/null +++ b/src/narrativeos/commercialization/__init__.py @@ -0,0 +1,17 @@ +from .config import ( + CommercializationConfigError, + CommercializationConfigPaths, + load_billing_metering, + load_commercial_plans, +) +from .models import BillingProfile, CommercialPlan, CustomerAccount + +__all__ = [ + "BillingProfile", + "CommercialPlan", + "CommercializationConfigError", + "CommercializationConfigPaths", + "CustomerAccount", + "load_billing_metering", + "load_commercial_plans", +] diff --git a/src/narrativeos/commercialization/config.py b/src/narrativeos/commercialization/config.py new file mode 100644 index 0000000..b27af9f --- /dev/null +++ b/src/narrativeos/commercialization/config.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Optional + +import yaml + +from .models import CommercialPlan + + +BASE_DIR = Path(__file__).resolve().parents[3] +DEFAULT_COMMERCIALIZATION_CONFIG_DIR = BASE_DIR / "configs" / "commercialization" + + +class CommercializationConfigError(ValueError): + pass + + +@dataclass(frozen=True) +class CommercializationConfigPaths: + config_dir: Path = DEFAULT_COMMERCIALIZATION_CONFIG_DIR + + @property + def plans(self) -> Path: + return self.config_dir / "plans.yaml" + + @property + def billing_metering(self) -> Path: + return self.config_dir / "billing_metering.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 CommercializationConfigError("commercialization_config_missing:%s" % path) from exc + if not isinstance(payload, dict): + raise CommercializationConfigError("commercialization_config_invalid_root:%s" % path) + return payload + + +def load_commercial_plans(paths: Optional[CommercializationConfigPaths] = None) -> Dict[str, Any]: + resolved_paths = paths or CommercializationConfigPaths() + payload = _load_yaml(resolved_paths.plans) + if "config_version" not in payload or "plans" not in payload: + raise CommercializationConfigError("commercialization_plans_missing_keys") + plans = [CommercialPlan.from_dict(item) for item in list(payload.get("plans") or [])] + if not plans: + raise CommercializationConfigError("commercialization_plans_empty") + return { + "config_version": str(payload.get("config_version") or ""), + "plans": plans, + "plan_map": {plan.plan_id: plan for plan in plans}, + } + + +def load_billing_metering(paths: Optional[CommercializationConfigPaths] = None) -> Dict[str, Any]: + resolved_paths = paths or CommercializationConfigPaths() + payload = _load_yaml(resolved_paths.billing_metering) + if "config_version" not in payload or "metrics" not in payload: + raise CommercializationConfigError("commercialization_billing_metering_missing_keys") + metrics = dict(payload.get("metrics") or {}) + if not metrics: + raise CommercializationConfigError("commercialization_billing_metering_empty") + for metric_id, metric_payload in metrics.items(): + entry = dict(metric_payload or {}) + if "display_name" not in entry or "included_units" not in entry or "unit_price_usd" not in entry: + raise CommercializationConfigError("commercialization_billing_metering_invalid:%s" % metric_id) + return { + "config_version": str(payload.get("config_version") or ""), + "metrics": metrics, + "credit_policy": dict(payload.get("credit_policy") or {}), + } diff --git a/src/narrativeos/commercialization/models.py b/src/narrativeos/commercialization/models.py new file mode 100644 index 0000000..d5cea68 --- /dev/null +++ b/src/narrativeos/commercialization/models.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, Optional + + +COMMERCIAL_ACCOUNT_STATUSES = {"trial", "active", "paused", "canceled", "renewal_due"} +COMMERCIAL_PLAN_STATUSES = {"active", "paused", "retired"} +BILLING_PROFILE_STATUSES = {"active", "paused", "disabled"} + + +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 _non_negative_int(value: Any, *, name: str) -> int: + try: + normalized = int(value) + except (TypeError, ValueError) as exc: + raise ValueError("%s_invalid:%s" % (name, value)) from exc + if normalized < 0: + raise ValueError("%s_negative:%s" % (name, normalized)) + return normalized + + +@dataclass +class CommercialPlan: + plan_id: str + display_name: str + subscription_tier: str + monthly_price_usd: float + status: str + seat_limit: int + workspace_limit: int + campaign_limit: int + features: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.status = _validate_enum(self.status, name="commercial_plan_status", allowed=COMMERCIAL_PLAN_STATUSES) + self.seat_limit = _non_negative_int(self.seat_limit, name="seat_limit") + self.workspace_limit = _non_negative_int(self.workspace_limit, name="workspace_limit") + self.campaign_limit = _non_negative_int(self.campaign_limit, name="campaign_limit") + self.monthly_price_usd = float(self.monthly_price_usd) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "CommercialPlan": + payload = dict(data or {}) + return cls( + plan_id=str(payload.get("plan_id") or ""), + display_name=str(payload.get("display_name") or ""), + subscription_tier=str(payload.get("subscription_tier") or ""), + monthly_price_usd=float(payload.get("monthly_price_usd", 0.0) or 0.0), + status=str(payload.get("status") or ""), + seat_limit=payload.get("seat_limit", 0), + workspace_limit=payload.get("workspace_limit", 0), + campaign_limit=payload.get("campaign_limit", 0), + features=dict(payload.get("features") or {}), + ) + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +@dataclass +class CustomerAccount: + customer_account_id: str + account_id: str + status: str + plan_id: str + seat_limit: int + workspace_limit: int + campaign_limit: int + seat_count: int = 0 + workspace_count: int = 0 + campaign_count: int = 0 + display_name: Optional[str] = None + renewal_due_at: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.status = _validate_enum(self.status, name="customer_account_status", allowed=COMMERCIAL_ACCOUNT_STATUSES) + self.seat_limit = _non_negative_int(self.seat_limit, name="seat_limit") + self.workspace_limit = _non_negative_int(self.workspace_limit, name="workspace_limit") + self.campaign_limit = _non_negative_int(self.campaign_limit, name="campaign_limit") + self.seat_count = _non_negative_int(self.seat_count, name="seat_count") + self.workspace_count = _non_negative_int(self.workspace_count, name="workspace_count") + self.campaign_count = _non_negative_int(self.campaign_count, name="campaign_count") + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "CustomerAccount": + payload = dict(data or {}) + display_name = payload.get("display_name") + return cls( + customer_account_id=str(payload.get("customer_account_id") or ""), + account_id=str(payload.get("account_id") or ""), + status=str(payload.get("status") or ""), + plan_id=str(payload.get("plan_id") or ""), + seat_limit=payload.get("seat_limit", 0), + workspace_limit=payload.get("workspace_limit", 0), + campaign_limit=payload.get("campaign_limit", 0), + seat_count=payload.get("seat_count", 0), + workspace_count=payload.get("workspace_count", 0), + campaign_count=payload.get("campaign_count", 0), + display_name=str(display_name) if display_name is not None else None, + renewal_due_at=str(payload.get("renewal_due_at")) if payload.get("renewal_due_at") is not None else None, + metadata=dict(payload.get("metadata") or {}), + ) + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +@dataclass +class BillingProfile: + billing_profile_id: str + customer_account_id: str + account_id: str + provider: str + status: str + invoice_email: Optional[str] = None + legal_name: Optional[str] = None + billing_country: Optional[str] = None + tax_status: Optional[str] = None + provider_customer_ref: Optional[str] = None + profile_payload: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.status = _validate_enum(self.status, name="billing_profile_status", allowed=BILLING_PROFILE_STATUSES) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "BillingProfile": + payload = dict(data or {}) + return cls( + billing_profile_id=str(payload.get("billing_profile_id") or ""), + customer_account_id=str(payload.get("customer_account_id") or ""), + account_id=str(payload.get("account_id") or ""), + provider=str(payload.get("provider") or ""), + status=str(payload.get("status") or ""), + invoice_email=str(payload.get("invoice_email")) if payload.get("invoice_email") is not None else None, + legal_name=str(payload.get("legal_name")) if payload.get("legal_name") is not None else None, + billing_country=str(payload.get("billing_country")) if payload.get("billing_country") is not None else None, + tax_status=str(payload.get("tax_status")) if payload.get("tax_status") is not None else None, + provider_customer_ref=str(payload.get("provider_customer_ref")) if payload.get("provider_customer_ref") is not None else None, + profile_payload=dict(payload.get("profile_payload") or {}), + ) + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) 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..b4d76be 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,21 @@ 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_longform_mode = bool( + getattr(state_before, "current_series_id", None) + or getattr(state_before, "current_volume_id", None) + or getattr(state_before, "current_arc_id", None) + or dict(getattr(state_before, "metadata", {}) or {}).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 +2674,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 +2721,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 +2740,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 +2802,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/learned_cadence.py b/src/narrativeos/eval/learned_cadence.py index 2500f4c..6260847 100644 --- a/src/narrativeos/eval/learned_cadence.py +++ b/src/narrativeos/eval/learned_cadence.py @@ -178,7 +178,10 @@ def _track_stage( if relevant_example_count <= 0: return "collect_data" if (latest_training_run or {}).get("status") == "failed": - return "train_candidate" + failed_at = _parse_timestamp((latest_training_run or {}).get("generated_at")) + trained_at = _parse_timestamp(str(freshness.get("trained_at") or "")) + if not artifact_present or trained_at is None or failed_at is None or failed_at >= trained_at: + return "train_candidate" if not artifact_present or freshness.get("data_newer_than_artifact"): return "train_candidate" if shadow_status != "candidate": diff --git a/src/narrativeos/eval/scorers.py b/src/narrativeos/eval/scorers.py index bf01c33..b6f0cfb 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: @@ -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/preflight.py b/src/narrativeos/persistence/preflight.py new file mode 100644 index 0000000..0252cbf --- /dev/null +++ b/src/narrativeos/persistence/preflight.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import os +from typing import Any, Dict, Optional +from urllib.parse import urlsplit + +from sqlalchemy import text +from sqlalchemy.exc import OperationalError + +from ..runtime_env import load_local_env +from .db import create_platform_engine, is_postgres_url + + +def _redact_url(value: Optional[str]) -> Optional[str]: + normalized = str(value or "").strip() + if not normalized: + return None + parsed = urlsplit(normalized) + host = parsed.hostname or "unknown-host" + database_name = parsed.path.lstrip("/") or "unknown-database" + return f"{host}/{database_name} (database-url-redacted)" + + +def _env_enabled(name: str) -> bool: + return str(os.getenv(name, "") or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _is_neon_pooler_host(host: Optional[str]) -> bool: + return "-pooler" in str(host or "").lower() + + +def _classify_database_error(message: str) -> str: + lowered = str(message or "").lower() + if "password authentication failed" in lowered or "authentication failed" in lowered: + return "authentication_failed" + if "could not translate host name" in lowered or "name or service not known" in lowered: + return "dns_unreachable" + if "connection refused" in lowered or "timeout expired" in lowered or "could not connect to server" in lowered: + return "network_unreachable" + if "ssl" in lowered or "channel_binding" in lowered: + return "ssl_configuration_error" + return "connection_failed" + + +def database_preflight(*, database_url: Optional[str] = None, load_env_files: bool = True) -> Dict[str, Any]: + if load_env_files: + load_local_env() + resolved_url = str(database_url or os.getenv("DATABASE_URL", "")).strip() + if not resolved_url: + return { + "ok": False, + "reason": "missing_database_url", + "database_kind": "missing", + "database_url_redacted": None, + } + + database_kind = "postgres" if is_postgres_url(resolved_url) else ("sqlite" if resolved_url.lower().startswith("sqlite:") else "other") + parsed = urlsplit(resolved_url) + payload: Dict[str, Any] = { + "ok": False, + "reason": None, + "database_kind": database_kind, + "database_url_redacted": _redact_url(resolved_url), + "host": parsed.hostname, + "database_name": parsed.path.lstrip("/") or None, + } + + try: + engine = create_platform_engine(resolved_url) + with engine.connect() as connection: + probe = connection.execute(text("select 1")).scalar() + payload.update( + { + "ok": True, + "reason": "ok", + "probe_result": probe, + } + ) + return payload + except OperationalError as exc: + message = str(exc) + payload.update( + { + "reason": _classify_database_error(message), + "error": message, + } + ) + return payload + except Exception as exc: # pragma: no cover - defensive classification + payload.update( + { + "reason": "connection_failed", + "error": str(exc), + } + ) + return payload + + +def public_beta_database_readiness( + *, + database_url: Optional[str] = None, + direct_database_url: Optional[str] = None, + read_replica_url: Optional[str] = None, + load_env_files: bool = True, + run_live_probe: bool = True, +) -> Dict[str, Any]: + if load_env_files: + load_local_env() + runtime_url = str(database_url or os.getenv("DATABASE_URL", "")).strip() + direct_url = str( + direct_database_url + or os.getenv("DATABASE_URL_UNPOOLED", "") + or os.getenv("POSTGRES_URL_NON_POOLING", "") + ).strip() + replica_url = str(read_replica_url or os.getenv("NARRATIVEOS_READ_REPLICA_DATABASE_URL", "")).strip() + failover_enabled = _env_enabled("NARRATIVEOS_DATABASE_FAILOVER_SQLITE") + runtime_parsed = urlsplit(runtime_url) if runtime_url else None + direct_parsed = urlsplit(direct_url) if direct_url else None + replica_parsed = urlsplit(replica_url) if replica_url else None + + checks: Dict[str, bool] = { + "runtime_database_url_present": bool(runtime_url), + "runtime_database_is_postgres": bool(runtime_url and is_postgres_url(runtime_url)), + "runtime_database_uses_neon_pooler": bool(runtime_parsed and _is_neon_pooler_host(runtime_parsed.hostname)), + "direct_database_url_present": bool(direct_url), + "direct_database_is_postgres": bool(direct_url and is_postgres_url(direct_url)), + "direct_database_is_not_pooler": bool(direct_parsed and not _is_neon_pooler_host(direct_parsed.hostname)), + "read_replica_url_present": bool(replica_url), + "read_replica_is_postgres": bool(replica_url and is_postgres_url(replica_url)), + "read_replica_uses_neon_pooler": bool(replica_parsed and _is_neon_pooler_host(replica_parsed.hostname)), + "sqlite_failover_disabled": not failover_enabled, + } + runtime_probe: Dict[str, Any] = {"ok": None, "reason": "not_run"} + if run_live_probe and runtime_url: + runtime_probe = database_preflight(database_url=runtime_url, load_env_files=False) + checks["runtime_database_live_probe_ok"] = bool(runtime_probe.get("ok")) + + blockers = [ + { + "key": key, + "severity": "high", + "detail": "Public Beta database readiness check failed.", + } + for key, passed in checks.items() + if not passed + ] + return { + "schema_version": "public_beta_database_readiness/v1", + "ready": not blockers, + "status": "passed" if not blockers else "blocked", + "checks": checks, + "blockers": blockers, + "runtime_database": { + "url_redacted": _redact_url(runtime_url), + "host": runtime_parsed.hostname if runtime_parsed else None, + "database_name": runtime_parsed.path.lstrip("/") if runtime_parsed else None, + "pooled": bool(runtime_parsed and _is_neon_pooler_host(runtime_parsed.hostname)), + "live_probe": runtime_probe, + }, + "direct_database": { + "url_redacted": _redact_url(direct_url), + "host": direct_parsed.hostname if direct_parsed else None, + "database_name": direct_parsed.path.lstrip("/") if direct_parsed else None, + "pooled": bool(direct_parsed and _is_neon_pooler_host(direct_parsed.hostname)), + }, + "read_replica_database": { + "url_redacted": _redact_url(replica_url), + "host": replica_parsed.hostname if replica_parsed else None, + "database_name": replica_parsed.path.lstrip("/") if replica_parsed else None, + "pooled": bool(replica_parsed and _is_neon_pooler_host(replica_parsed.hostname)), + "configured": bool(replica_url), + }, + "sqlite_failover": { + "enabled": failover_enabled, + "env_var": "NARRATIVEOS_DATABASE_FAILOVER_SQLITE", + }, + } 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..3ad1c97 100644 --- a/src/narrativeos/providers.py +++ b/src/narrativeos/providers.py @@ -11,10 +11,11 @@ from urllib import error as urlerror from urllib import request as urlrequest -from .canon import hard_constraint_errors +from .canon import effective_rating_ceiling, hard_constraint_errors from .models import CandidateBatch, EventAtom, NarrativeState, WorldBible from .prompts import get_prompt_text, render_candidate_user_prompt from .schemas import validate_payload +from .scene_functions import normalize_scene_function class LLMBackend(ABC): @@ -468,9 +469,19 @@ def generate_json(self, *, system_prompt: str, user_prompt: str) -> Any: class StaticCandidateProvider(CandidateProvider): _LONG_ROUTE_CONTINUATION_MIN_END_TURN = 10 + _DUTY_FUNCTION_PRIORITIES: Dict[str, List[str]] = { + "advance_plot": ["false_peace", "truth_trial", "debt_exchange", "temptation"], + "advance_relationship": ["temptation", "misrecognition", "confession_window", "truth_trial"], + "resolve_promise": ["debt_exchange", "confession_window", "truth_trial", "karma_ripening"], + "expand_world": ["false_peace", "truth_trial", "karma_ripening", "debt_exchange"], + "pace_breath": ["confession_window", "false_peace", "misrecognition", "temptation"], + "deliver_climax": ["karma_ripening", "truth_trial", "humiliation", "debt_exchange"], + } def __init__(self, event_pool: Sequence[EventAtom]) -> None: self.event_pool = [EventAtom.from_dict(event.to_dict()) for event in event_pool] + self._batch_cache = RuntimePromptCache(max_entries=256) + self._continuation_template_cache = RuntimePromptCache(max_entries=256) _CONTINUATION_FUNCTIONS_BY_PHASE: Dict[str, List[str]] = { "setup": ["false_peace", "temptation", "confession_window"], @@ -522,18 +533,25 @@ def __init__(self, event_pool: Sequence[EventAtom]) -> None: } def _continuation_functions(self, state: NarrativeState) -> List[str]: + duty_type = str((state.current_chapter_task or {}).get("duty_type") or "") + duty_functions = list(self._DUTY_FUNCTION_PRIORITIES.get(duty_type, [])) phase_functions = list( self._CONTINUATION_FUNCTIONS_BY_PHASE.get( state.story_phase, self._CONTINUATION_FUNCTIONS_BY_PHASE["midpoint"], ) ) + combined = list(dict.fromkeys(duty_functions + phase_functions)) recent = [ str(scene_function) - for scene_function in state.recent_scene_functions[-2:] + for scene_function in state.recent_scene_functions[-3:] ] - preferred = [scene_function for scene_function in phase_functions if scene_function not in recent] - return preferred or phase_functions + preferred = [scene_function for scene_function in combined if scene_function not in recent] + if preferred: + rotation = max(0, int(state.chapter_index)) % len(preferred) + return preferred[rotation:] + preferred[:rotation] + rotation = max(0, int(state.chapter_index)) % len(combined) if combined else 0 + return combined[rotation:] + combined[:rotation] if combined else phase_functions def _continuation_title(self, scene_function: str, location: str, *, index: int) -> str: base = self._SCENE_FUNCTION_LABELS.get(scene_function, "局势又往前推了一步") @@ -563,25 +581,102 @@ def _continuation_promises( scene_function: str, actors: Sequence[str], ) -> List[Dict[str, Any]]: - if state.chapter_index >= state.min_end_turn or len(state.open_promises) >= 3: + promise_actions = set(str(item) for item in list((state.current_chapter_task or {}).get("promise_actions") or [])) + promise_targets = [str(item) for item in list((state.current_chapter_task or {}).get("promise_targets") or []) if str(item)] + progression = dict((state.metadata or {}).get("longform_progression") or {}) + target_chapters = int(progression.get("series_target_chapters", 0) or 0) + protected_runway = target_chapters and int(state.chapter_index or 0) < int(target_chapters * 0.96) + if ( + (state.chapter_index >= state.min_end_turn and not protected_runway) + or (len(state.open_promises) >= 3 and "open_follow_on_promise" not in promise_actions) + ): return [] holders = list(dict.fromkeys(list(actors[:2]) or list(actors[:1]))) if not holders: return [] + promise_id = f"{event_id}__promise" + description = "这一步逼出来的话,迟早要在后面的章节里被真正认下。" + existing_promise_ids = {str(promise.promise_id) for promise in state.open_promises if str(getattr(promise, "promise_id", ""))} + if promise_targets: + preferred_promise_id = promise_targets[0] + if preferred_promise_id not in existing_promise_ids: + promise_id = preferred_promise_id + description = f"{preferred_promise_id} 这条线索/承诺必须在后续章节继续追上来。" + else: + promise_id = f"{event_id}__follow_on_promise" + description = f"{preferred_promise_id} 还没收住,需要在后续章节再打开一条新的追问。" return [ { - "promise_id": f"{event_id}__promise", - "description": "这一步逼出来的话,迟早要在后面的章节里被真正认下。", + "promise_id": promise_id, + "description": description, "opened_at_turn": state.turn_index, "due_by_turn": state.turn_index + 2, "holders": holders, "fulfillment_modes": ["truth", "choice", "confession"], "status": "open", "stakes": "medium", - "tags": [scene_function, "story_thread"], + "tags": [scene_function, "story_thread", "runway"], } ] + def _continuation_promises_close( + self, + *, + state: NarrativeState, + scene_function: str, + actors: Sequence[str], + ) -> List[str]: + duty_type = str((state.current_chapter_task or {}).get("duty_type") or "") + promise_actions = set(str(item) for item in list((state.current_chapter_task or {}).get("promise_actions") or [])) + actor_set = {str(actor) for actor in actors if str(actor)} + overdue = [ + promise + for promise in state.open_promises + if promise.status == "open" and int(promise.due_by_turn or 0) <= int(state.turn_index or 0) + ] + if not overdue: + overdue = [promise for promise in state.open_promises if promise.status == "open"] + if not overdue: + return [] + close_budget = 0 + if duty_type in {"resolve_promise", "deliver_climax"}: + close_budget = 2 + elif duty_type in {"pace_breath", "advance_relationship", "expand_world"} and ( + len(state.open_promises) >= 5 + or "advance_payoff" in promise_actions + or "close_arc_loop" in promise_actions + ): + close_budget = 1 + if scene_function in {"debt_exchange", "karma_ripening", "confession_window", "truth_trial"}: + close_budget = max(close_budget, 1) + if close_budget <= 0: + return [] + + def sort_key(promise): + holder_overlap = bool(actor_set & set(promise.holders)) + stakes = str(promise.stakes or "") + return ( + 0 if holder_overlap else 1, + 0 if stakes not in {"low", "medium"} else 1, + int(promise.opened_at_turn or 0), + int(promise.due_by_turn or 0), + promise.promise_id, + ) + + selected = [] + for promise in sorted(overdue, key=sort_key): + if promise.promise_id not in selected: + selected.append(promise.promise_id) + if len(selected) >= close_budget: + break + progression = dict((state.metadata or {}).get("longform_progression") or {}) + target_chapters = int(progression.get("series_target_chapters", 0) or 0) + protected_runway = target_chapters and int(state.chapter_index or 0) < int(target_chapters * 0.8) + if protected_runway and (len(state.open_promises) - len(selected)) <= 0: + while selected and (len(state.open_promises) - len(selected)) <= 0: + selected.pop() + return selected + def _continuation_seeds( self, *, @@ -612,6 +707,118 @@ def _continuation_seeds( } ] + def _continuation_blueprint_templates( + self, + base_event: EventAtom, + *, + state: NarrativeState, + world: WorldBible, + scene_functions: Sequence[str], + limit: int, + ) -> List[Dict[str, Any]]: + raw_blueprints = list((base_event.metadata or {}).get("continuation_blueprints") or []) + if limit <= 0 or not raw_blueprints: + return [] + base_payload = base_event.to_dict() + templates: List[Dict[str, Any]] = [] + fallback_functions = list(scene_functions) or [base_event.scene_function] + current_duty = str((state.current_chapter_task or {}).get("duty_type") or "") + current_phase = str(state.story_phase or "") + for blueprint_index, raw in enumerate(raw_blueprints): + payload = dict(raw or {}) + duty_allowlist = {str(item) for item in list(payload.get("duty_allowlist") or []) if str(item)} + duty_denylist = {str(item) for item in list(payload.get("duty_denylist") or []) if str(item)} + phase_allowlist = {str(item) for item in list(payload.get("phase_allowlist") or []) if str(item)} + phase_denylist = {str(item) for item in list(payload.get("phase_denylist") or []) if str(item)} + if duty_allowlist and current_duty not in duty_allowlist: + continue + if duty_denylist and current_duty in duty_denylist: + continue + if phase_allowlist and current_phase not in phase_allowlist: + continue + if phase_denylist and current_phase in phase_denylist: + continue + scene_function = normalize_scene_function( + str(payload.get("scene_function") or fallback_functions[blueprint_index % len(fallback_functions)]) + ) + tags = list( + dict.fromkeys( + list(base_event.tags) + + list(payload.get("tags") or []) + + list((world.creator_controls.theme_targets or [])[:2]) + + [scene_function] + ) + ) + metadata = dict(base_event.metadata or {}) + metadata.pop("continuation_blueprints", None) + metadata.update( + { + "continuation_variant": True, + "base_event_id": base_event.event_id, + "continuation_phase": payload.get("phase") or "", + "continuation_blueprint_id": str(payload.get("blueprint_id") or f"{base_event.event_id}::{scene_function}::{blueprint_index}"), + "generated_from_static_pool": True, + } + ) + if payload.get("next_continuation_blueprints"): + metadata["continuation_blueprints"] = list(payload.get("next_continuation_blueprints") or []) + if payload.get("scene_quality_contract"): + metadata["scene_quality_contract"] = dict(payload.get("scene_quality_contract") or {}) + if payload.get("scene_blueprint_id"): + metadata["scene_blueprint_id"] = str(payload.get("scene_blueprint_id") or "") + location = str(payload.get("location") or base_event.location or (world.locations[blueprint_index % len(world.locations)] if world.locations else "")) + templates.append( + { + "base_event_id": base_event.event_id, + "index": blueprint_index, + "actors": list(payload.get("actors") or base_event.actors), + "title": str(payload.get("title") or self._continuation_title(scene_function, location, index=blueprint_index)), + "summary": str( + payload.get("summary") + or self._continuation_summary( + scene_function=scene_function, + location=location, + world=world, + tags=tags, + ) + ), + "scene_function": scene_function, + "tags": tags, + "belief_updates": dict(payload.get("belief_updates") or base_payload.get("belief_updates") or {}), + "trust_deltas": list(payload.get("trust_deltas") or base_payload.get("trust_deltas") or []), + "emotion_deltas": list(payload.get("emotion_deltas") or base_payload.get("emotion_deltas") or []), + "rating_ceiling": str(payload.get("rating_ceiling") or base_event.rating_ceiling or world.creator_controls.darkness_ceiling or "PG13"), + "tension_delta": float(payload.get("tension_delta") or self._SCENE_FUNCTION_TENSION.get(scene_function, max(0.08, float(base_event.tension_delta)))), + "theme_impacts": dict( + payload.get("theme_impacts") + or {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(payload.get("agency_affordances") or base_event.agency_affordances) + + list(tags[:2]) + + ["continue_story"] + ) + ), + "promises_close": list( + dict.fromkeys( + list(payload.get("promises_close") or []) + + self._continuation_promises_close( + state=state, + scene_function=scene_function, + actors=list(payload.get("actors") or base_event.actors), + ) + ) + ), + "location": location, + "convergence_key": str(payload.get("convergence_key") or base_event.convergence_key or f"continuation::{scene_function}"), + "metadata": metadata, + } + ) + if len(templates) >= limit: + break + return templates + def _continuation_variant( self, base_event: EventAtom, @@ -687,6 +894,129 @@ def _continuation_variant( ) return EventAtom.from_dict(payload) + def _continuation_template_cache_key( + self, + *, + state: NarrativeState, + world: WorldBible, + scene_functions: Sequence[str], + limit: int, + ) -> str: + payload = { + "world_id": world.world_id, + "story_phase": state.story_phase, + "duty_type": str((state.current_chapter_task or {}).get("duty_type") or ""), + "scene_functions": list(scene_functions), + "limit": int(limit), + "rating_ceiling": state.rating_ceiling or world.creator_controls.darkness_ceiling, + "theme_targets": list(world.creator_controls.theme_targets or world.themes), + "locations": list(world.locations or []), + "event_pool_ids": [event.event_id for event in self.event_pool], + "event_pool_continuation_blueprints": { + event.event_id: list((event.metadata or {}).get("continuation_blueprints") or []) + for event in self.event_pool + if (event.metadata or {}).get("continuation_blueprints") + }, + } + return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest() + + def _continuation_variant_templates( + self, + state: NarrativeState, + world: WorldBible, + *, + scene_functions: Sequence[str], + limit: int, + ) -> List[Dict[str, Any]]: + if limit <= 0 or not self.event_pool: + return [] + cache_key = self._continuation_template_cache_key( + state=state, + world=world, + scene_functions=scene_functions, + limit=limit, + ) + cached = self._continuation_template_cache.get(cache_key) + if isinstance(cached, list): + return [dict(item) for item in cached] + templates: List[Dict[str, Any]] = [] + for base_index, base_event in enumerate(self.event_pool): + blueprint_templates = self._continuation_blueprint_templates( + base_event, + state=state, + world=world, + scene_functions=scene_functions, + limit=max(0, limit - len(templates)), + ) + if blueprint_templates: + templates.extend(blueprint_templates) + if len(templates) >= limit: + self._continuation_template_cache.set(cache_key, templates) + return [dict(item) for item in templates] + continue + base_payload = base_event.to_dict() + for function_index, scene_function in enumerate(scene_functions): + index = (base_index * len(scene_functions)) + function_index + tags = list(dict.fromkeys(list(base_event.tags) + list((world.creator_controls.theme_targets or [])[:2]) + [scene_function])) + metadata = dict(base_payload.get("metadata", {})) + for key in ( + "terminal", + "endgame_shape", + "ending_gate", + "required_fate_pressure", + "required_inescapable_nodes", + ): + metadata.pop(key, None) + metadata.update( + { + "continuation_variant": True, + "base_event_id": base_event.event_id, + "continuation_phase": state.story_phase, + "generated_from_static_pool": True, + } + ) + world_locations = list(world.locations or []) + rotated_location = world_locations[index % len(world_locations)] if world_locations else base_event.location + templates.append( + { + "base_event_id": base_event.event_id, + "index": index, + "actors": list(base_event.actors), + "title": self._continuation_title(scene_function, rotated_location, index=index), + "summary": self._continuation_summary( + scene_function=scene_function, + location=rotated_location, + world=world, + tags=tags, + ), + "scene_function": scene_function, + "tags": tags, + "belief_updates": dict(base_payload.get("belief_updates", {})), + "trust_deltas": list(base_payload.get("trust_deltas", [])), + "emotion_deltas": list(base_payload.get("emotion_deltas", [])), + "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"])), + "promises_close": self._continuation_promises_close( + state=state, + scene_function=scene_function, + actors=list(base_event.actors), + ), + "location": rotated_location, + "convergence_key": base_event.convergence_key or f"continuation::{scene_function}", + "metadata": metadata, + } + ) + if len(templates) >= limit: + self._continuation_template_cache.set(cache_key, templates) + return [dict(item) for item in templates] + self._continuation_template_cache.set(cache_key, templates) + return [dict(item) for item in templates] + def _continuation_candidates( self, state: NarrativeState, @@ -700,23 +1030,102 @@ 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): - for function_index, scene_function in enumerate(scene_functions): - variant = self._continuation_variant( - base_event, + templates = self._continuation_variant_templates( + state, + world, + scene_functions=scene_functions, + limit=limit, + ) + visited_event_ids = set(str(event_id) for event_id in state.visited_event_ids) + for template in templates: + variant_id = f"{template['base_event_id']}__continuation__{state.chapter_index + 1}_{template['index']}_{template['scene_function']}" + if variant_id in event_ids or variant_id in visited_event_ids: + continue + payload = { + "event_id": variant_id, + "title": template["title"], + "summary": template["summary"], + "location": template["location"], + "actors": list(template["actors"]), + "scene_function": template["scene_function"], + "tags": list(template["tags"]), + "preconditions_all": [], + "forbidden_if_any": [], + "world_fact_deltas_add": [f"continuation::{state.chapter_index + 1}::{template['scene_function']}::{template['index']}"], + "world_fact_deltas_remove": [], + "belief_updates": dict(template["belief_updates"]), + "trust_deltas": list(template["trust_deltas"]), + "emotion_deltas": list(template["emotion_deltas"]), + "promises_open": self._continuation_promises( state=state, - world=world, - scene_function=scene_function, - index=(base_index * len(scene_functions)) + function_index, - ) - if variant.event_id in event_ids or variant.event_id in state.visited_event_ids: - continue - event_ids.add(variant.event_id) - variants.append(variant) - if len(variants) >= limit: - return variants + event_id=variant_id, + scene_function=str(template["scene_function"]), + actors=list(template["actors"]), + ), + "promises_close": list(template.get("promises_close") or self._continuation_promises_close( + state=state, + scene_function=str(template["scene_function"]), + actors=list(template["actors"]), + )), + "rating_ceiling": template["rating_ceiling"], + "tension_delta": template["tension_delta"], + "theme_impacts": dict(template["theme_impacts"]), + "agency_affordances": list(template["agency_affordances"]), + "karmic_seed_creations": self._continuation_seeds( + event_id=variant_id, + scene_function=str(template["scene_function"]), + actors=list(template["actors"]), + tags=list(template["tags"]), + ), + "karmic_seed_resolutions": [], + "convergence_key": template["convergence_key"], + "metadata": dict(template["metadata"]), + } + variant = EventAtom.from_dict(payload) + event_ids.add(variant.event_id) + variants.append(variant) + if len(variants) >= limit: + return variants return variants + def _should_use_longform_continuations(self, state: NarrativeState) -> bool: + if bool((state.metadata or {}).get("longform_plan_enabled")): + return True + if (state.metadata or {}).get("longform_plan", {}): + return True + return state.min_end_turn >= self._LONG_ROUTE_CONTINUATION_MIN_END_TURN + + def _cache_key( + self, + state: NarrativeState, + *, + world: WorldBible, + depth: int, + min_candidates: int, + max_candidates: int, + ) -> str: + payload = { + "world_id": world.world_id, + "depth": int(depth), + "min_candidates": int(min_candidates), + "max_candidates": int(max_candidates), + "story_phase": state.story_phase, + "chapter_index": int(state.chapter_index or 0), + "min_end_turn": int(state.min_end_turn or 0), + "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 {}), + "recent_scene_functions": list(state.recent_scene_functions[-4:]), + "world_facts": list(state.world_facts), + "visited_event_ids": list(state.visited_event_ids), + "open_promise_ids": sorted(promise.promise_id for promise in state.open_promises), + "closed_promise_ids": list((state.metadata or {}).get("closed_promise_ids", [])), + "scene_history": list((state.metadata or {}).get("scene_history", [])), + "diagnostics_mode": (state.metadata or {}).get("longform_diagnostics_mode"), + } + return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest() + def generate( self, state: NarrativeState, @@ -726,26 +1135,61 @@ def generate( min_candidates: int = 6, max_candidates: int = 10, ) -> CandidateBatch: + cache_key = self._cache_key( + state, + world=world, + depth=depth, + min_candidates=min_candidates, + max_candidates=max_candidates, + ) + cached_payload = self._batch_cache.get(cache_key) + if isinstance(cached_payload, dict): + cached_batch = CandidateBatch.from_dict(cached_payload) + cached_batch.debug["cache_hit"] = True + cached_batch.debug["cache_key"] = cache_key[:12] + return cached_batch + + visited_event_ids = set(str(event_id) for event_id in state.visited_event_ids) raw_candidates = [ - EventAtom.from_dict(event.to_dict()) + event for event in self.event_pool - if event.event_id not in state.visited_event_ids + if event.event_id not in visited_event_ids ][:max_candidates] + constraint_context = { + "facts": set(state.world_facts), + "state_character_ids": set(state.characters.keys()), + "world_character_ids": { + ( + str(item) + if isinstance(item, str) + else str(getattr(item, "character_id", "") or "") + ) + for item in list(world.characters or []) + if ( + (isinstance(item, str) and str(item)) + or getattr(item, "character_id", None) + ) + }, + "ceiling": effective_rating_ceiling(state, world=world), + "existing_promise_ids": {promise.promise_id for promise in state.open_promises}, + "closed_promise_ids": set(state.metadata.get("closed_promise_ids", [])), + "recent_scene_window": [normalize_scene_function(scene_function) for scene_function in state.recent_scene_functions[-2:]], + "scene_history": set(state.metadata.get("scene_history", [])), + "forbidden_moves": list(world.forbidden_moves), + } + legal_candidates: List[EventAtom] = [] illegal_candidate_reasons: Dict[str, List[str]] = {} for candidate in raw_candidates: - reasons = hard_constraint_errors(state, candidate, world=world) + reasons = hard_constraint_errors(state, candidate, world=world, context=constraint_context) if reasons: illegal_candidate_reasons[candidate.event_id] = reasons else: legal_candidates.append(candidate) continuation_candidates: List[EventAtom] = [] - if ( - state.min_end_turn >= self._LONG_ROUTE_CONTINUATION_MIN_END_TURN - and len(legal_candidates) < min_candidates - ): + if self._should_use_longform_continuations(state) and len(legal_candidates) < min_candidates: continuation_limit = max( 1, int(min_candidates - len(legal_candidates)), @@ -759,13 +1203,13 @@ def generate( ) for candidate in continuation_candidates: raw_candidates.append(candidate) - reasons = hard_constraint_errors(state, candidate, world=world) + reasons = hard_constraint_errors(state, candidate, world=world, context=constraint_context) if reasons: illegal_candidate_reasons[candidate.event_id] = reasons else: legal_candidates.append(candidate) - return CandidateBatch( + batch = CandidateBatch( raw_candidates=raw_candidates, legal_candidates=legal_candidates, illegal_candidate_reasons=illegal_candidate_reasons, @@ -776,8 +1220,13 @@ def generate( "legal_count": len(legal_candidates), "min_candidates_requested": min_candidates, "continuation_candidate_count": len(continuation_candidates), + "continuation_mode": "longform" if self._should_use_longform_continuations(state) else "standard", + "cache_hit": False, + "cache_key": cache_key[:12], }, ) + self._batch_cache.set(cache_key, batch.to_dict()) + return batch class LLMCandidateProvider(CandidateProvider): @@ -937,6 +1386,129 @@ def generate_json(self, *, system_prompt: str, user_prompt: str) -> Any: return self.payload +class DeepSeekProvider(LLMBackend): + RETRYABLE_HTTP_STATUS = {408, 409, 425, 429, 500, 502, 503, 504} + + def __init__( + self, + api_key: Optional[str] = None, + model: Optional[str] = None, + *, + base_url: Optional[str] = None, + timeout_seconds: Optional[float] = None, + max_tokens: Optional[int] = None, + ) -> None: + self.api_key = api_key or os.getenv("DEEPSEEK_API_KEY") + self.model = model or os.getenv("NARRATIVEOS_DEEPSEEK_MODEL", "deepseek-v4-flash") + self.base_url = (base_url or os.getenv("NARRATIVEOS_DEEPSEEK_BASE_URL", "https://api.deepseek.com")).rstrip("/") + self.timeout_seconds = float(timeout_seconds or os.getenv("NARRATIVEOS_DEEPSEEK_TIMEOUT_SECONDS", "90")) + self.max_tokens = int(max_tokens or os.getenv("NARRATIVEOS_DEEPSEEK_MAX_TOKENS", "4096")) + self.provider_id = "deepseek" + self.last_route_debug: Dict[str, Any] = {} + + def _request_body(self, *, system_prompt: str, user_prompt: str) -> Dict[str, Any]: + return { + "model": self.model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "response_format": {"type": "json_object"}, + "thinking": {"type": "disabled"}, + "max_tokens": self.max_tokens, + "stream": False, + } + + def _parse_message_content(self, content: str) -> Any: + text = str(content or "").strip() + if text.startswith("```"): + text = text.strip("`").strip() + if text.startswith("json"): + text = text[4:].strip() + return json.loads(text) + + def generate_json(self, *, system_prompt: str, user_prompt: str) -> Any: + if not self.api_key: + raise RuntimeError("DEEPSEEK_API_KEY is required for DeepSeekProvider") + body = self._request_body(system_prompt=system_prompt, user_prompt=user_prompt) + started = perf_counter() + req = urlrequest.Request( + f"{self.base_url}/chat/completions", + data=json.dumps(body, ensure_ascii=False).encode("utf-8"), + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + method="POST", + ) + try: + with urlrequest.urlopen(req, timeout=self.timeout_seconds) as response: # noqa: S310 + raw_response = response.read().decode("utf-8") + except urlerror.HTTPError as exc: + error_body = exc.read().decode("utf-8", errors="replace")[:500] + retryable = int(exc.code) in self.RETRYABLE_HTTP_STATUS + self.last_route_debug = { + "provider": self.provider_id, + "selected_provider": self.provider_id, + "model": self.model, + "succeeded": False, + "backend_error": f"deepseek_http_{exc.code}", + "http_status": int(exc.code), + "retryable": retryable, + "latency_ms": round((perf_counter() - started) * 1000.0, 3), + } + raise ProviderExecutionError( + self.provider_id, + f"deepseek_http_{exc.code}: {error_body}", + retryable=retryable, + ) from exc + except (TimeoutError, urlerror.URLError) as exc: + self.last_route_debug = { + "provider": self.provider_id, + "selected_provider": self.provider_id, + "model": self.model, + "succeeded": False, + "backend_error": str(exc), + "retryable": True, + "latency_ms": round((perf_counter() - started) * 1000.0, 3), + } + raise ProviderExecutionError(self.provider_id, str(exc), retryable=True) from exc + + try: + response_payload = json.loads(raw_response) + choice = (response_payload.get("choices") or [{}])[0] + message = choice.get("message") or {} + content = message.get("content") or "" + parsed_payload = self._parse_message_content(content) if content else response_payload + except Exception as exc: + self.last_route_debug = { + "provider": self.provider_id, + "selected_provider": self.provider_id, + "model": self.model, + "succeeded": False, + "backend_error": "deepseek_invalid_json", + "retryable": True, + "latency_ms": round((perf_counter() - started) * 1000.0, 3), + } + raise ProviderExecutionError(self.provider_id, f"deepseek_invalid_json: {exc}", retryable=True) from exc + + usage = dict(response_payload.get("usage") or {}) + self.last_route_debug = { + "provider": self.provider_id, + "selected_provider": self.provider_id, + "model": self.model, + "returned_model": response_payload.get("model"), + "finish_reason": choice.get("finish_reason") if isinstance(choice, dict) else None, + "usage": usage, + "prompt_tokens": usage.get("prompt_tokens"), + "completion_tokens": usage.get("completion_tokens"), + "cache_hit": bool(usage.get("prompt_cache_hit_tokens")), + "succeeded": True, + "latency_ms": round((perf_counter() - started) * 1000.0, 3), + } + return parsed_payload + + class OpenAIProvider(LLMBackend): def __init__(self, api_key: Optional[str] = None, model: str = "gpt-5") -> None: self.api_key = api_key or os.getenv("OPENAI_API_KEY") @@ -1024,7 +1596,10 @@ def build_llm_backend_from_env(scope: Optional[str] = None) -> Optional[LLMBacke backends: List[LLMBackend] = [] provider_ids: List[str] = [] for provider_name in provider_order: - if provider_name == "openai" and os.getenv("OPENAI_API_KEY"): + if provider_name == "deepseek" and os.getenv("DEEPSEEK_API_KEY"): + backends.append(DeepSeekProvider(model=os.getenv("NARRATIVEOS_DEEPSEEK_MODEL", "deepseek-v4-flash"))) + provider_ids.append("deepseek") + elif provider_name == "openai" and os.getenv("OPENAI_API_KEY"): backends.append(OpenAIProvider(model=os.getenv("NARRATIVEOS_OPENAI_MODEL", "gpt-5"))) provider_ids.append("openai") elif provider_name == "anthropic" and os.getenv("ANTHROPIC_API_KEY"): 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..ef57e0b 100644 --- a/src/narrativeos/repetition_detector.py +++ b/src/narrativeos/repetition_detector.py @@ -2,16 +2,551 @@ 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 + numerator = sum(float(value) * float(right.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/analytics.py b/src/narrativeos/services/analytics.py index 35e76b3..69ef404 100644 --- a/src/narrativeos/services/analytics.py +++ b/src/narrativeos/services/analytics.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Dict +from typing import Any, Callable, Dict, List, Set from ..persistence.repositories import SQLAlchemyPlatformRepository @@ -8,6 +8,10 @@ class AnalyticsService: def __init__(self, repository: SQLAlchemyPlatformRepository) -> None: self.repository = repository + self._listeners: List[tuple[Set[str], Callable[[Dict[str, Any]], None]]] = [] + + def register_listener(self, event_names: set[str], callback: Callable[[Dict[str, Any]], None]) -> None: + self._listeners.append((set(event_names), callback)) def track(self, event_name: str, **payload: Any) -> Dict[str, Any]: reserved = { @@ -27,12 +31,21 @@ def track(self, event_name: str, **payload: Any) -> Dict[str, Any]: **dict(payload.get("payload_json", {})), **{key: value for key, value in payload.items() if key not in reserved and value is not None}, } - return self.repository.record_analytics_event( + saved = self.repository.record_analytics_event( { "event_name": event_name, "reader_id": payload.get("reader_id"), "session_id": payload.get("session_id"), "world_version_id": payload.get("world_version_id"), "payload_json": {key: value for key, value in payload_json.items() if value is not None}, + "occurred_at": payload.get("occurred_at"), } ) + for listener_event_names, callback in self._listeners: + if event_name not in listener_event_names: + continue + try: + callback(saved) + except Exception: + continue + return saved diff --git a/src/narrativeos/services/async_jobs.py b/src/narrativeos/services/async_jobs.py index b6d503e..8cbb110 100644 --- a/src/narrativeos/services/async_jobs.py +++ b/src/narrativeos/services/async_jobs.py @@ -18,6 +18,7 @@ build_retry_policy_registry, classify_adapter_failure, ) +from .reader_generation_jobs import READER_GENERATION_JOB_TYPE, compact_reader_generation_result ASYNC_JOB_ASSET_TYPE = "async_job" @@ -32,6 +33,12 @@ DEFAULT_HANDOFF_SLA_MINUTES = 240 JOB_STEP_TEMPLATES: Dict[str, List[Dict[str, str]]] = { + READER_GENERATION_JOB_TYPE: [ + {"key": "queued", "label": "Queued"}, + {"key": "generate", "label": "Generate Chapter"}, + {"key": "persist", "label": "Persist Result"}, + {"key": "completed", "label": "Completed"}, + ], "learned_training": [ {"key": "queued", "label": "Queued"}, {"key": "training", "label": "Train Tracks"}, @@ -692,6 +699,8 @@ def _compact_result(self, job_type: str, result: Dict[str, Any]) -> Dict[str, An "stdout_log": dict(result.get("artifacts") or {}).get("stdout_log"), "stderr_log": dict(result.get("artifacts") or {}).get("stderr_log"), } + if job_type == READER_GENERATION_JOB_TYPE: + return compact_reader_generation_result(result) return result def list_jobs( diff --git a/src/narrativeos/services/auth.py b/src/narrativeos/services/auth.py index b9cb55b..b70c8c0 100644 --- a/src/narrativeos/services/auth.py +++ b/src/narrativeos/services/auth.py @@ -3,28 +3,143 @@ from datetime import datetime, timedelta, timezone import hashlib import hmac +import logging +import os import secrets -from typing import Any, Dict, Optional +from typing import Any, Dict, Mapping, Optional from ..persistence.repositories import SQLAlchemyPlatformRepository +from .emailing import EmailDeliveryError, EmailService TOKEN_TTL_DAYS = 14 +DEFAULT_REFRESH_TOKEN_TTL_DAYS = 30 +DEFAULT_VERIFICATION_TOKEN_TTL_SECONDS = 48 * 60 * 60 +DEFAULT_PASSWORD_RESET_TOKEN_TTL_SECONDS = 2 * 60 * 60 +DEFAULT_VERIFICATION_RESEND_COOLDOWN_SECONDS = 60 +ADMIN_VIEW_BRIDGE_TTL_HOURS = 1 +ADMIN_VIEW_ALLOWED_ROLES = {"reviewer", "ops", "admin"} +AUTH_COOKIE_NAME = "narrativeos_auth" +AUTH_COOKIE_SAMESITE = "lax" + +logger = logging.getLogger(__name__) + + +def _env_int(name: str, default: int) -> int: + raw = str(os.getenv(name, "") or "").strip() + if not raw: + return default + try: + return max(1, int(raw)) + except ValueError: + return default + + +def _bool_env(name: str, default: bool = False) -> bool: + raw = str(os.getenv(name, "") or "").strip().lower() + if not raw: + return default + return raw in {"1", "true", "yes", "on"} + + +class AuthServiceError(RuntimeError): + def __init__( + self, + *, + http_status: int, + code: str, + reason: str, + stage: str, + retryable: bool = False, + action_hint: Optional[str] = None, + account_created: bool = False, + can_retry_send: bool = False, + can_resend_verification: bool = False, + next_allowed_at: Optional[str] = None, + identity: Optional[Dict[str, Any]] = None, + email_provider_status: Optional[Dict[str, Any]] = None, + extra: Optional[Dict[str, Any]] = None, + ) -> None: + super().__init__(reason) + self.http_status = http_status + self.code = code + self.reason = reason + self.stage = stage + self.retryable = retryable + self.action_hint = action_hint + self.account_created = account_created + self.can_retry_send = can_retry_send + self.can_resend_verification = can_resend_verification + self.next_allowed_at = next_allowed_at + self.identity = dict(identity or {}) + self.email_provider_status = dict(email_provider_status or {}) + self.extra = dict(extra or {}) + + def detail(self) -> Dict[str, Any]: + payload = { + "code": self.code, + "reason": self.reason, + "stage": self.stage, + "retryable": self.retryable, + "action_hint": self.action_hint, + "account_created": self.account_created, + "can_retry_send": self.can_retry_send, + "can_resend_verification": self.can_resend_verification, + "next_allowed_at": self.next_allowed_at, + "identity": self.identity or None, + "email_provider_status": self.email_provider_status or None, + } + payload.update(self.extra) + return {key: value for key, value in payload.items() if value is not None} class AuthService: - def __init__(self, repository: SQLAlchemyPlatformRepository) -> None: + def __init__(self, repository: SQLAlchemyPlatformRepository, *, email_service: Optional[EmailService] = None) -> None: self.repository = repository + self.email_service = email_service or EmailService() def _utcnow(self) -> str: return datetime.now(timezone.utc).isoformat() + def _parse_timestamp(self, value: Optional[str]) -> Optional[datetime]: + normalized = str(value or "").strip() + if not normalized: + return None + try: + return datetime.fromisoformat(normalized.replace("Z", "+00:00")) + except ValueError: + return None + def _token_hash(self, raw_token: str) -> str: return hashlib.sha256(raw_token.encode("utf-8")).hexdigest() + def _app_base_url(self) -> str: + return str( + os.getenv("APP_BASE_URL") + or os.getenv("NARRATIVEOS_APP_BASE_URL") + or "http://127.0.0.1:8000/app" + ) + + def _reviewer_app_base_url(self) -> str: + configured = str(os.getenv("NARRATIVEOS_REVIEWER_APP_BASE_URL", "")).strip() + if configured: + return configured + return self._app_base_url().rstrip("/") + "/reviewer" + def _password_salt(self) -> str: return secrets.token_hex(16) + def _default_ui_preferences(self) -> Dict[str, Any]: + return { + "immersiveEffects": False, + "autoRenderArt": False, + "privacyMode": True, + "streamSpeed": 3, + "particleDensity": 50, + "fontSize": "medium", + "theme": "quantum", + } + def _password_hash(self, password: str, salt: str) -> str: digest = hashlib.pbkdf2_hmac( "sha256", @@ -38,49 +153,170 @@ def _verify_password(self, password: str, *, password_hash: str, salt: str) -> b candidate = self._password_hash(password, salt) return hmac.compare_digest(candidate, password_hash) - def register_identity( + def _looks_like_email(self, value: Optional[str]) -> bool: + return bool(str(value or "").strip() and "@" in str(value or "")) + + def _is_admin_view_role(self, actor_role: Optional[str]) -> bool: + return str(actor_role or "").strip() in ADMIN_VIEW_ALLOWED_ROLES + + def _verification_token_ttl_seconds(self) -> int: + return _env_int("EMAIL_VERIFICATION_TOKEN_TTL", DEFAULT_VERIFICATION_TOKEN_TTL_SECONDS) + + def _password_reset_token_ttl_seconds(self) -> int: + return _env_int("PASSWORD_RESET_TOKEN_TTL", DEFAULT_PASSWORD_RESET_TOKEN_TTL_SECONDS) + + def _verification_resend_cooldown_seconds(self) -> int: + return _env_int("EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS", DEFAULT_VERIFICATION_RESEND_COOLDOWN_SECONDS) + + def _refresh_token_ttl_days(self) -> int: + return _env_int("AUTH_REFRESH_TOKEN_TTL_DAYS", DEFAULT_REFRESH_TOKEN_TTL_DAYS) + + def _auth_cookie_name(self) -> str: + return str(os.getenv("AUTH_COOKIE_NAME", AUTH_COOKIE_NAME) or AUTH_COOKIE_NAME).strip() or AUTH_COOKIE_NAME + + def _auth_cookie_secure(self) -> bool: + if os.getenv("AUTH_COOKIE_SECURE") is not None: + return _bool_env("AUTH_COOKIE_SECURE", False) + return self._app_base_url().startswith("https://") + + def _auth_cookie_domain(self) -> Optional[str]: + value = str(os.getenv("AUTH_COOKIE_DOMAIN", "") or "").strip() + return value or None + + def _auth_cookie_samesite(self) -> str: + value = str(os.getenv("AUTH_COOKIE_SAMESITE", AUTH_COOKIE_SAMESITE) or AUTH_COOKIE_SAMESITE).strip().lower() + return value if value in {"lax", "strict", "none"} else AUTH_COOKIE_SAMESITE + + def auth_cookie_settings(self) -> Dict[str, Any]: + return { + "key": self._auth_cookie_name(), + "max_age": TOKEN_TTL_DAYS * 24 * 60 * 60, + "secure": self._auth_cookie_secure(), + "httponly": True, + "samesite": self._auth_cookie_samesite(), + "path": "/", + "domain": self._auth_cookie_domain(), + } + + def extract_request_token( self, *, - actor_id: str, - actor_role: str, - password: str, - account_id: Optional[str] = None, - display_name: Optional[str] = None, - ) -> Dict[str, Any]: - resolved_actor_id = str(actor_id or "").strip() - if not resolved_actor_id: - raise ValueError("actor_id_required") - if not str(password or "").strip(): - raise ValueError("password_required") - salt = self._password_salt() - record = self.repository.save_auth_identity( + authorization: Optional[str] = None, + cookies: Optional[Mapping[str, Any]] = None, + ) -> Optional[str]: + bearer = str(authorization or "").strip() + if bearer.lower().startswith("bearer "): + raw_token = bearer.split(" ", 1)[1].strip() + if raw_token: + return raw_token + cookie_name = self._auth_cookie_name() + if cookies and cookies.get(cookie_name): + raw_token = str(cookies.get(cookie_name) or "").strip() + if raw_token: + return raw_token + return None + + def _security_profile(self, *, actor_id: str, account_id: Optional[str]) -> Dict[str, Any]: + existing = self.repository.get_auth_identity_profile(actor_id, default=None) + if existing: + existing.setdefault("ui_preferences_json", dict(self._default_ui_preferences())) + return existing + email_address = actor_id if self._looks_like_email(actor_id) else (account_id if self._looks_like_email(account_id) else None) + return self.repository.save_auth_identity_profile( { - "actor_id": resolved_actor_id, - "account_id": str(account_id or "").strip() or None, - "actor_role": str(actor_role or "author").strip() or "author", - "display_name": display_name, - "password_hash": self._password_hash(password, salt), - "password_salt": salt, - "status": "active", + "actor_id": actor_id, + "account_id": account_id, + "email_address": email_address, + "avatar_url": None, + "pending_email_address": None, + "email_verified": False if self._looks_like_email(actor_id) else True, + "verification_required": bool(self._looks_like_email(actor_id)), + "ui_preferences_json": self._default_ui_preferences(), + "pending_email_change_requested_at": None, + "email_change_last_sent_at": None, } ) + + def _verification_send_state(self, profile: Dict[str, Any]) -> Dict[str, Any]: + sent_at = self._parse_timestamp(profile.get("verification_sent_at")) + cooldown_seconds = self._verification_resend_cooldown_seconds() + next_allowed_at = sent_at + timedelta(seconds=cooldown_seconds) if sent_at else None + can_resend = next_allowed_at is None or next_allowed_at <= datetime.now(timezone.utc) return { - "identity": { - "actor_id": record["actor_id"], - "account_id": record.get("account_id"), - "actor_role": record["actor_role"], - "display_name": record.get("display_name"), - "status": record["status"], - "created_at": record["created_at"], - } + "verification_resend_cooldown_seconds": cooldown_seconds, + "verification_next_allowed_at": next_allowed_at.isoformat() if next_allowed_at else None, + "verification_can_resend": can_resend, } - def issue_token(self, *, actor_id: str, password: str) -> Dict[str, Any]: - identity = self.repository.get_auth_identity(actor_id) - if identity.get("status") != "active": - raise PermissionError("auth_identity_inactive") - if not self._verify_password(password, password_hash=identity["password_hash"], salt=identity["password_salt"]): - raise PermissionError("invalid_credentials") + def _serialize_security(self, *, actor_id: str, account_id: Optional[str]) -> Dict[str, Any]: + profile = self._security_profile(actor_id=actor_id, account_id=account_id) + ui_preferences = dict(self._default_ui_preferences()) + ui_preferences.update(dict(profile.get("ui_preferences_json") or {})) + return { + "email_address": profile.get("email_address"), + "pending_email_address": profile.get("pending_email_address"), + "avatar_url": profile.get("avatar_url"), + "email_verified": bool(profile.get("email_verified")), + "verification_required": bool(profile.get("verification_required")), + "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": ui_preferences, + "deactivated_at": profile.get("deactivated_at"), + "deactivated_by": profile.get("deactivated_by"), + "deactivation_reason": profile.get("deactivation_reason"), + "email_provider_status": self.email_service.provider_status(), + **self._verification_send_state(profile), + } + + def _current_email_address(self, *, identity: Dict[str, Any], security: Optional[Dict[str, Any]] = None) -> Optional[str]: + resolved_security = security or self._serialize_security( + actor_id=str(identity.get("actor_id") or ""), + account_id=identity.get("account_id"), + ) + candidates = [ + resolved_security.get("email_address"), + identity.get("account_id"), + identity.get("actor_id"), + ] + for candidate in candidates: + normalized = str(candidate or "").strip() + if self._looks_like_email(normalized): + return normalized + return None + + def resolve_actor_id_from_identifier(self, identifier: str) -> str: + normalized = str(identifier or "").strip() + if not normalized: + raise ValueError("identifier_required") + if self._looks_like_email(normalized): + profile = self.repository.get_auth_identity_profile_by_email_address(normalized, default=None) + if profile is not None: + return str(profile.get("actor_id") or normalized) + identity = self.repository.get_auth_identity_by_account_id(normalized, default=None) + if identity is not None: + return str(identity.get("actor_id") or normalized) + raise KeyError("unknown_auth_identity_for_identifier:%s" % normalized) + try: + self.repository.get_auth_identity(normalized) + return normalized + except KeyError: + identity = self.repository.get_auth_identity_by_account_id(normalized, default=None) + if identity is not None: + return str(identity.get("actor_id") or normalized) + raise + + def _revoke_refresh_tokens_for_actor(self, actor_id: str, *, status: str = "revoked") -> None: + self.repository.update_auth_flow_tokens_for_actor( + actor_id=actor_id, + flow_type="auth_refresh", + statuses=["active"], + updates={"status": status, "consumed_at": self._utcnow()}, + ) + + def _issue_session_tokens(self, *, identity: Dict[str, Any], security: Dict[str, Any]) -> Dict[str, Any]: raw_token = f"ntos_{secrets.token_urlsafe(32)}" expires_at = (datetime.now(timezone.utc) + timedelta(days=TOKEN_TTL_DAYS)).isoformat() token = self.repository.save_auth_token( @@ -94,17 +330,33 @@ def issue_token(self, *, actor_id: str, password: str) -> Dict[str, Any]: "last_used_at": self._utcnow(), } ) + self._supersede_flow_tokens(actor_id=identity["actor_id"], flow_type="auth_refresh") + refresh = self._issue_flow_token( + actor_id=identity["actor_id"], + account_id=identity.get("account_id"), + flow_type="auth_refresh", + ttl_seconds=self._refresh_token_ttl_days() * 24 * 60 * 60, + payload_json={ + "actor_role": identity["actor_role"], + "issued_from_token_id": token["token_id"], + }, + ) return { "token": { "access_token": raw_token, "token_type": "bearer", "expires_at": expires_at, }, + "refresh": { + "refresh_token": refresh["token"], + "expires_at": refresh["expires_at"], + }, "identity": { "actor_id": identity["actor_id"], "account_id": identity.get("account_id"), "actor_role": identity["actor_role"], "display_name": identity.get("display_name"), + **security, }, "session": { "token_id": token["token_id"], @@ -112,20 +364,577 @@ def issue_token(self, *, actor_id: str, password: str) -> Dict[str, Any]: }, } + def _public_flow_token(self, flow: Dict[str, Any]) -> Dict[str, Any]: + payload = { + "flow_token_id": flow.get("flow_token_id"), + "flow_type": flow.get("flow_type"), + "expires_at": flow.get("expires_at"), + } + if self.email_service.expose_debug_tokens() and flow.get("token"): + payload["token"] = flow.get("token") + return payload + + def _record_delivery_attempt( + self, + *, + identity: Dict[str, Any], + flow_type: str, + recipient_email: str, + status: str, + delivery: Optional[Dict[str, Any]] = None, + error: Optional[EmailDeliveryError] = None, + ) -> Dict[str, Any]: + provider_status = self.email_service.provider_status() + payload = { + "actor_id": identity.get("actor_id"), + "account_id": identity.get("account_id"), + "flow_type": flow_type, + "provider": str((delivery or {}).get("provider") or provider_status.get("provider") or ""), + "email_mode": str(provider_status.get("mode") or ""), + "sender_email": provider_status.get("from_email"), + "recipient_email": recipient_email, + "status": status, + "provider_message_id": (delivery or {}).get("message_id"), + "error_code": error.reason if error else None, + "error_reason": error.metadata.get("response_message") if error else None, + "retryable": bool(error.retryable) if error else False, + "metadata_json": { + "provider_status": provider_status, + "delivery": dict(delivery or {}), + "error": error.detail() if error else None, + }, + } + return self.repository.save_auth_delivery_attempt(payload) + + def _issue_flow_token( + self, + *, + actor_id: str, + account_id: Optional[str], + flow_type: str, + ttl_seconds: int, + payload_json: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + raw_token = f"nflow_{secrets.token_urlsafe(24)}" + expires_at = (datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)).isoformat() + try: + record = self.repository.save_auth_flow_token( + { + "actor_id": actor_id, + "account_id": account_id, + "flow_type": flow_type, + "token_hash": self._token_hash(raw_token), + "status": "active", + "payload_json": dict(payload_json or {}), + "expires_at": expires_at, + } + ) + except Exception as exc: # pragma: no cover - defensive classification + logger.exception("auth flow token issue failed", extra={"actor_id": actor_id, "flow_type": flow_type, "stage": "token_issue"}) + raise AuthServiceError( + http_status=503, + code="auth_token_issue_failed", + reason="token_issue_failed", + stage="token_issue", + retryable=True, + action_hint="retry_request", + email_provider_status=self.email_service.provider_status(), + extra={"flow_type": flow_type}, + ) from exc + return { + "token": raw_token, + "flow_token_id": record["flow_token_id"], + "flow_type": flow_type, + "expires_at": expires_at, + } + + def _resolve_active_flow_token(self, *, raw_token: str, flow_type: str) -> Dict[str, Any]: + token = self.repository.get_auth_flow_token_by_hash(self._token_hash(raw_token), flow_type=flow_type) + token_status = str(token.get("status") or "active") + if token_status != "active": + if token_status == "consumed": + raise PermissionError("auth_flow_token_consumed") + if token_status == "expired": + raise PermissionError("auth_flow_token_expired") + if token_status == "superseded": + raise PermissionError("auth_flow_token_superseded") + raise PermissionError("auth_flow_token_inactive") + expires_at = self._parse_timestamp(token.get("expires_at")) + if expires_at and expires_at < datetime.now(timezone.utc): + self.repository.update_auth_flow_token(token["flow_token_id"], {"status": "expired"}) + raise PermissionError("auth_flow_token_expired") + return token + + def _supersede_flow_tokens(self, *, actor_id: str, flow_type: str, exclude_flow_token_id: Optional[str] = None) -> None: + self.repository.update_auth_flow_tokens_for_actor( + actor_id=actor_id, + flow_type=flow_type, + statuses=["active"], + exclude_flow_token_id=exclude_flow_token_id, + updates={"status": "superseded"}, + ) + + def _send_verification_email(self, *, identity: Dict[str, Any], verification_token: str) -> Dict[str, Any]: + security = self._serialize_security(actor_id=str(identity.get("actor_id") or ""), account_id=identity.get("account_id")) + email_address = self._current_email_address(identity=identity, security=security) + if not self._looks_like_email(email_address): + raise ValueError("email_verification_not_applicable") + try: + delivery = self.email_service.send_verification_email( + to_email=str(email_address), + display_name=identity.get("display_name"), + verify_token=verification_token, + app_base_url=self._app_base_url(), + ) + except EmailDeliveryError as exc: + status = "blocked" if exc.reason in {"provider_not_configured", "test_mode_external_recipient_blocked", "domain_not_verified"} else "failed" + self._record_delivery_attempt( + identity=identity, + flow_type="email_verification", + recipient_email=str(email_address), + status=status, + error=exc, + ) + logger.warning( + "verification email send failed", + extra={"actor_id": identity.get("actor_id"), "flow_type": "email_verification", "reason": exc.reason, "stage": "email_delivery"}, + ) + raise + self._record_delivery_attempt( + identity=identity, + flow_type="email_verification", + recipient_email=str(email_address), + status="queued", + delivery=delivery, + ) + self.repository.save_auth_identity_profile( + { + **self._security_profile(actor_id=identity["actor_id"], account_id=identity.get("account_id")), + "verification_sent_at": self._utcnow(), + } + ) + return delivery + + def _send_password_reset_email(self, *, identity: Dict[str, Any], reset_token: str) -> Dict[str, Any]: + security = self._serialize_security(actor_id=str(identity.get("actor_id") or ""), account_id=identity.get("account_id")) + email_address = self._current_email_address(identity=identity, security=security) + if not self._looks_like_email(email_address): + raise ValueError("password_reset_not_applicable") + try: + delivery = self.email_service.send_password_reset_email( + to_email=str(email_address), + display_name=identity.get("display_name"), + reset_token=reset_token, + app_base_url=self._app_base_url(), + ) + except EmailDeliveryError as exc: + status = "blocked" if exc.reason in {"provider_not_configured", "test_mode_external_recipient_blocked", "domain_not_verified"} else "failed" + self._record_delivery_attempt( + identity=identity, + flow_type="password_reset", + recipient_email=str(email_address), + status=status, + error=exc, + ) + logger.warning( + "password reset email send failed", + extra={"actor_id": identity.get("actor_id"), "flow_type": "password_reset", "reason": exc.reason, "stage": "email_delivery"}, + ) + raise + self._record_delivery_attempt( + identity=identity, + flow_type="password_reset", + recipient_email=str(email_address), + status="queued", + delivery=delivery, + ) + self.repository.save_auth_identity_profile( + { + **self._security_profile(actor_id=identity["actor_id"], account_id=identity.get("account_id")), + "password_reset_sent_at": self._utcnow(), + } + ) + return delivery + + def _send_email_change_email( + self, + *, + identity: Dict[str, Any], + new_email: str, + email_change_token: str, + ) -> Dict[str, Any]: + if not self._looks_like_email(new_email): + raise ValueError("email_change_not_applicable") + try: + delivery = self.email_service.send_email_change_email( + to_email=str(new_email), + display_name=identity.get("display_name"), + email_change_token=email_change_token, + app_base_url=self._app_base_url(), + ) + except EmailDeliveryError as exc: + status = "blocked" if exc.reason in {"provider_not_configured", "test_mode_external_recipient_blocked", "domain_not_verified"} else "failed" + self._record_delivery_attempt( + identity=identity, + flow_type="email_change", + recipient_email=str(new_email), + status=status, + error=exc, + ) + logger.warning( + "email change email send failed", + extra={"actor_id": identity.get("actor_id"), "flow_type": "email_change", "reason": exc.reason, "stage": "email_delivery"}, + ) + raise + self._record_delivery_attempt( + identity=identity, + flow_type="email_change", + recipient_email=str(new_email), + status="queued", + delivery=delivery, + ) + self.repository.save_auth_identity_profile( + { + **self._security_profile(actor_id=identity["actor_id"], account_id=identity.get("account_id")), + "email_change_last_sent_at": self._utcnow(), + } + ) + return delivery + + def register_identity( + self, + *, + actor_id: str, + actor_role: str, + password: str, + account_id: Optional[str] = None, + display_name: Optional[str] = None, + ) -> Dict[str, Any]: + resolved_actor_id = str(actor_id or "").strip() + if not resolved_actor_id: + raise ValueError("actor_id_required") + if not str(password or "").strip(): + raise ValueError("password_required") + resolved_actor_role = str(actor_role or "author").strip() or "author" + resolved_account_id = str(account_id or "").strip() or None + if resolved_actor_role in {"reader", "customer"}: + if resolved_account_id and resolved_account_id != resolved_actor_id: + raise ValueError("%s_account_id_must_match_actor_id" % resolved_actor_role) + resolved_account_id = resolved_actor_id + try: + self.repository.get_auth_identity(resolved_actor_id) + except KeyError: + pass + else: + raise ValueError("actor_id_already_registered") + if self._looks_like_email(resolved_actor_id): + try: + self.email_service.auth_preflight(to_email=resolved_actor_id, flow_type="email_verification") + except EmailDeliveryError as exc: + self._record_delivery_attempt( + identity={ + "actor_id": resolved_actor_id, + "account_id": resolved_account_id, + }, + flow_type="email_verification", + recipient_email=resolved_actor_id, + status="blocked", + error=exc, + ) + logger.warning( + "auth register blocked before account creation", + extra={"actor_id": resolved_actor_id, "reason": exc.reason, "stage": "email_policy"}, + ) + raise AuthServiceError( + http_status=403 if exc.reason == "test_mode_external_recipient_blocked" else 503, + code="auth_register_delivery_failed", + reason=exc.reason, + stage="email_policy", + retryable=exc.retryable, + action_hint=exc.action_hint, + email_provider_status=exc.provider_status, + ) from exc + salt = self._password_salt() + try: + record = self.repository.save_auth_identity( + { + "actor_id": resolved_actor_id, + "account_id": resolved_account_id, + "actor_role": resolved_actor_role, + "display_name": display_name, + "password_hash": self._password_hash(password, salt), + "password_salt": salt, + "status": "active", + } + ) + except Exception as exc: # pragma: no cover - defensive classification + logger.exception("auth identity create failed", extra={"actor_id": resolved_actor_id, "stage": "db_write"}) + raise AuthServiceError( + http_status=503, + code="auth_register_delivery_failed", + reason="db_write_failed", + stage="db_write", + retryable=True, + action_hint="retry_request", + email_provider_status=self.email_service.provider_status(), + ) from exc + verification = None + delivery = None + if self._looks_like_email(resolved_actor_id): + verification = self._issue_flow_token( + actor_id=record["actor_id"], + account_id=record.get("account_id"), + flow_type="email_verification", + ttl_seconds=self._verification_token_ttl_seconds(), + ) + try: + delivery = self._send_verification_email(identity=record, verification_token=verification["token"]) + except EmailDeliveryError as exc: + identity_payload = { + "actor_id": record["actor_id"], + "account_id": record.get("account_id"), + "actor_role": record["actor_role"], + "display_name": record.get("display_name"), + "status": record["status"], + "created_at": record["created_at"], + **self._serialize_security(actor_id=record["actor_id"], account_id=record.get("account_id")), + } + raise AuthServiceError( + http_status=503 if exc.reason != "test_mode_external_recipient_blocked" else 403, + code="auth_register_delivery_failed", + reason=exc.reason, + stage="email_delivery", + retryable=exc.retryable, + action_hint=exc.action_hint, + account_created=True, + can_retry_send=True, + identity=identity_payload, + email_provider_status=exc.provider_status, + ) from exc + return { + "identity": { + "actor_id": record["actor_id"], + "account_id": record.get("account_id"), + "actor_role": record["actor_role"], + "display_name": record.get("display_name"), + "status": record["status"], + "created_at": record["created_at"], + **self._serialize_security(actor_id=record["actor_id"], account_id=record.get("account_id")), + }, + "verification": self._public_flow_token(verification) if verification else None, + "delivery": delivery, + } + + def issue_token(self, *, actor_id: str, password: str) -> Dict[str, Any]: + identity = self.repository.get_auth_identity(actor_id) + if identity.get("status") != "active": + raise PermissionError("auth_identity_inactive") + if not self._verify_password(password, password_hash=identity["password_hash"], salt=identity["password_salt"]): + raise PermissionError("invalid_credentials") + security = self._serialize_security(actor_id=identity["actor_id"], account_id=identity.get("account_id")) + if security.get("verification_required") and not security.get("email_verified"): + raise AuthServiceError( + http_status=403, + code="auth_email_unverified", + reason="email_verification_required", + stage="login_policy", + action_hint="request_verification_email", + can_resend_verification=bool(security.get("verification_can_resend", True)), + next_allowed_at=security.get("verification_next_allowed_at"), + identity={ + "actor_id": identity["actor_id"], + "account_id": identity.get("account_id"), + "actor_role": identity["actor_role"], + "display_name": identity.get("display_name"), + **security, + }, + email_provider_status=security.get("email_provider_status"), + ) + return self._issue_session_tokens(identity=identity, security=security) + + def refresh_access_token(self, *, raw_refresh_token: str) -> Dict[str, Any]: + resolved = self._resolve_active_flow_token(raw_token=raw_refresh_token, flow_type="auth_refresh") + identity = self.repository.get_auth_identity(resolved["actor_id"]) + if identity.get("status") != "active": + self.repository.update_auth_flow_token( + resolved["flow_token_id"], + {"status": "revoked", "consumed_at": self._utcnow()}, + ) + raise PermissionError("auth_identity_inactive") + security = self._serialize_security(actor_id=identity["actor_id"], account_id=identity.get("account_id")) + if security.get("verification_required") and not security.get("email_verified"): + raise AuthServiceError( + http_status=403, + code="auth_email_unverified", + reason="email_verification_required", + stage="refresh_policy", + action_hint="request_verification_email", + can_resend_verification=bool(security.get("verification_can_resend", True)), + next_allowed_at=security.get("verification_next_allowed_at"), + identity={ + "actor_id": identity["actor_id"], + "account_id": identity.get("account_id"), + "actor_role": identity["actor_role"], + "display_name": identity.get("display_name"), + **security, + }, + email_provider_status=security.get("email_provider_status"), + ) + self.repository.update_auth_flow_token( + resolved["flow_token_id"], + {"status": "consumed", "consumed_at": self._utcnow()}, + ) + return self._issue_session_tokens(identity=identity, security=security) + + def authenticate_identity(self, *, actor_id: str, password: str) -> Dict[str, Any]: + identity = self.repository.get_auth_identity(actor_id) + if identity.get("status") != "active": + raise PermissionError("auth_identity_inactive") + if not self._verify_password(password, password_hash=identity["password_hash"], salt=identity["password_salt"]): + raise PermissionError("invalid_credentials") + return identity + + def issue_admin_view_bridge( + self, + *, + actor_id: str, + actor_role: str, + account_id: Optional[str] = None, + workspace: str = "review", + world_id: Optional[str] = None, + world_version_id: Optional[str] = None, + case_id: Optional[str] = None, + alert_id: Optional[str] = None, + ) -> Dict[str, Any]: + resolved_actor_id = str(actor_id or "").strip() + resolved_actor_role = str(actor_role or "").strip() + if not resolved_actor_id: + raise PermissionError("admin_view_identity_required") + if not self._is_admin_view_role(resolved_actor_role): + raise PermissionError("admin_view_role_forbidden") + resolved_workspace = str(workspace or "review").strip() or "review" + context = { + "account_id": str(account_id or "").strip() or None, + "world_id": str(world_id or "").strip() or None, + "world_version_id": str(world_version_id or "").strip() or None, + "case_id": str(case_id or "").strip() or None, + "alert_id": str(alert_id or "").strip() or None, + "workspace": resolved_workspace, + } + token = self._issue_flow_token( + actor_id=resolved_actor_id, + account_id=str(account_id or "").strip() or None, + flow_type="admin_view_bridge", + ttl_seconds=ADMIN_VIEW_BRIDGE_TTL_HOURS * 60 * 60, + payload_json={ + **context, + "actor_role": resolved_actor_role, + }, + ) + query = [ + "product=ops", + f"workspace={resolved_workspace}", + "admin_view=1", + f"admin_view_bridge={token['token']}", + ] + for key in ("account_id", "world_id", "world_version_id", "case_id", "alert_id"): + value = context.get(key) + if value: + query.append(f"{key}={value}") + return { + "bridge": self._public_flow_token(token), + "authorized": True, + "actor_id": resolved_actor_id, + "actor_role": resolved_actor_role, + "context": context, + "url": f"{self._reviewer_app_base_url()}?{'&'.join(query)}", + } + + def issue_admin_view_session_bridge( + self, + *, + actor_id: str, + password: str, + account_id: Optional[str] = None, + workspace: str = "review", + world_id: Optional[str] = None, + world_version_id: Optional[str] = None, + case_id: Optional[str] = None, + alert_id: Optional[str] = None, + ) -> Dict[str, Any]: + identity = self.authenticate_identity(actor_id=actor_id, password=password) + return self.issue_admin_view_bridge( + actor_id=str(identity.get("actor_id") or ""), + actor_role=str(identity.get("actor_role") or ""), + account_id=account_id or identity.get("account_id"), + workspace=workspace, + world_id=world_id, + world_version_id=world_version_id, + case_id=case_id, + alert_id=alert_id, + ) + + def resolve_admin_view_bridge_token(self, *, raw_token: str) -> Dict[str, Any]: + token = self._resolve_active_flow_token(raw_token=raw_token, flow_type="admin_view_bridge") + payload = dict(token.get("payload_json") or {}) + actor_id = str(token.get("actor_id") or "").strip() + actor_role = str(payload.get("actor_role") or "").strip() + if not actor_id: + raise PermissionError("admin_view_identity_required") + if not self._is_admin_view_role(actor_role): + raise PermissionError("admin_view_role_forbidden") + return { + "authorized": True, + "actor_id": actor_id, + "actor_role": actor_role, + "account_id": token.get("account_id"), + "bridge_token_id": token.get("flow_token_id"), + "expires_at": token.get("expires_at"), + "context": { + "account_id": payload.get("account_id"), + "world_id": payload.get("world_id"), + "world_version_id": payload.get("world_version_id"), + "case_id": payload.get("case_id"), + "alert_id": payload.get("alert_id"), + "workspace": payload.get("workspace") or "review", + }, + } + + def resolve_admin_view_bridge( + self, + *, + raw_token: str, + actor_id: str, + actor_role: str, + ) -> Dict[str, Any]: + resolved_actor_id = str(actor_id or "").strip() + resolved_actor_role = str(actor_role or "").strip() + if not resolved_actor_id: + raise PermissionError("admin_view_identity_required") + if not self._is_admin_view_role(resolved_actor_role): + raise PermissionError("admin_view_role_forbidden") + resolved = self.resolve_admin_view_bridge_token(raw_token=raw_token) + if str(resolved.get("actor_id") or "").strip() != resolved_actor_id: + raise PermissionError("admin_view_bridge_actor_mismatch") + if str(resolved.get("actor_role") or "").strip() != resolved_actor_role: + raise PermissionError("admin_view_bridge_role_mismatch") + return resolved + def resolve_bearer_token(self, raw_token: str) -> Dict[str, Any]: if not str(raw_token or "").strip(): raise PermissionError("missing_bearer_token") token = self.repository.get_auth_token_by_hash(self._token_hash(raw_token)) if token.get("status") != "active": raise PermissionError("inactive_bearer_token") - expires_at = token.get("expires_at") - if expires_at: - normalized = str(expires_at).replace("Z", "+00:00") - if datetime.fromisoformat(normalized) < datetime.now(timezone.utc): - self.repository.update_auth_token(token["token_id"], {"status": "expired"}) - raise PermissionError("expired_bearer_token") + expires_at = self._parse_timestamp(token.get("expires_at")) + if expires_at and expires_at < datetime.now(timezone.utc): + self.repository.update_auth_token(token["token_id"], {"status": "expired"}) + raise PermissionError("expired_bearer_token") updated = self.repository.update_auth_token(token["token_id"], {"last_used_at": self._utcnow()}) identity = self.repository.get_auth_identity(updated["actor_id"]) + if identity.get("status") != "active": + self.repository.update_auth_token(updated["token_id"], {"status": "revoked"}) + raise PermissionError("auth_identity_inactive") return { "actor_id": identity["actor_id"], "account_id": identity.get("account_id"), @@ -134,12 +943,537 @@ def resolve_bearer_token(self, raw_token: str) -> Dict[str, Any]: "token_id": updated["token_id"], "expires_at": updated.get("expires_at"), "last_used_at": updated.get("last_used_at"), + **self._serialize_security(actor_id=identity["actor_id"], account_id=identity.get("account_id")), } def revoke_bearer_token(self, raw_token: str) -> Dict[str, Any]: token = self.repository.get_auth_token_by_hash(self._token_hash(raw_token)) updated = self.repository.update_auth_token(token["token_id"], {"status": "revoked"}) + self._revoke_refresh_tokens_for_actor(updated["actor_id"], status="revoked") return { "token_id": updated["token_id"], "status": updated["status"], } + + def update_profile( + self, + *, + actor_id: str, + display_name: Optional[str] = None, + avatar_url: Optional[str] = None, + email_address: Optional[str] = None, + ) -> Dict[str, Any]: + identity = self.repository.get_auth_identity(actor_id) + security = self._security_profile(actor_id=identity["actor_id"], account_id=identity.get("account_id")) + normalized_display_name = identity.get("display_name") + if display_name is not None: + normalized_display_name = str(display_name or "").strip() or identity["actor_id"] + updated_identity = self.repository.save_auth_identity( + { + **identity, + "display_name": normalized_display_name, + "status": identity.get("status", "active"), + } + ) + normalized_avatar_url = security.get("avatar_url") + if avatar_url is not None: + normalized_avatar_url = str(avatar_url or "").strip() or None + if email_address is not None: + normalized_email = str(email_address or "").strip() + current_email = str(security.get("email_address") or "").strip() + if normalized_email and normalized_email != current_email: + raise ValueError("auth_profile_email_update_unsupported") + updated_profile = self.repository.save_auth_identity_profile( + { + **security, + "actor_id": updated_identity["actor_id"], + "account_id": updated_identity.get("account_id"), + "avatar_url": normalized_avatar_url, + } + ) + return { + "identity": { + "actor_id": updated_identity["actor_id"], + "account_id": updated_identity.get("account_id"), + "actor_role": updated_identity["actor_role"], + "display_name": updated_identity.get("display_name"), + **self._serialize_security(actor_id=updated_identity["actor_id"], account_id=updated_identity.get("account_id")), + }, + "profile": updated_profile, + } + + def change_password( + self, + *, + actor_id: str, + current_password: str, + new_password: str, + ) -> Dict[str, Any]: + if not str(current_password or "").strip(): + raise ValueError("current_password_required") + if not str(new_password or "").strip(): + raise ValueError("new_password_required") + identity = self.authenticate_identity(actor_id=actor_id, password=current_password) + salt = self._password_salt() + updated_identity = self.repository.save_auth_identity( + { + **identity, + "password_hash": self._password_hash(new_password, salt), + "password_salt": salt, + "status": identity.get("status", "active"), + } + ) + self.repository.revoke_auth_tokens_for_actor(updated_identity["actor_id"], reason="password_change") + self._revoke_refresh_tokens_for_actor(updated_identity["actor_id"], status="revoked") + security = self._serialize_security(actor_id=updated_identity["actor_id"], account_id=updated_identity.get("account_id")) + return self._issue_session_tokens(identity=updated_identity, security=security) + + def get_user_settings(self, *, actor_id: str) -> Dict[str, Any]: + identity = self.repository.get_auth_identity(actor_id) + security = self._security_profile(actor_id=identity["actor_id"], account_id=identity.get("account_id")) + settings = dict(self._default_ui_preferences()) + settings.update(dict(security.get("ui_preferences_json") or {})) + return settings + + def update_user_settings(self, *, actor_id: str, settings_updates: Dict[str, Any]) -> Dict[str, Any]: + identity = self.repository.get_auth_identity(actor_id) + security = self._security_profile(actor_id=identity["actor_id"], account_id=identity.get("account_id")) + next_settings = dict(self._default_ui_preferences()) + next_settings.update(dict(security.get("ui_preferences_json") or {})) + next_settings.update({key: value for key, value in dict(settings_updates or {}).items() if value is not None}) + if next_settings.get("fontSize") not in {"small", "medium", "large"}: + raise ValueError("auth_settings_font_size_invalid") + if next_settings.get("theme") not in {"quantum", "neon", "void", "solar"}: + raise ValueError("auth_settings_theme_invalid") + try: + next_settings["streamSpeed"] = int(next_settings.get("streamSpeed", 3)) + next_settings["particleDensity"] = int(next_settings.get("particleDensity", 50)) + except (TypeError, ValueError) as exc: + raise ValueError("auth_settings_numeric_invalid") from exc + next_settings["streamSpeed"] = max(1, min(5, next_settings["streamSpeed"])) + next_settings["particleDensity"] = max(0, min(100, next_settings["particleDensity"])) + next_settings["immersiveEffects"] = bool(next_settings.get("immersiveEffects")) + next_settings["autoRenderArt"] = bool(next_settings.get("autoRenderArt")) + next_settings["privacyMode"] = bool(next_settings.get("privacyMode")) + self.repository.save_auth_identity_profile( + { + **security, + "actor_id": identity["actor_id"], + "account_id": identity.get("account_id"), + "ui_preferences_json": next_settings, + } + ) + return next_settings + + def deactivate_account( + self, + *, + actor_id: str, + requested_by: Optional[str] = None, + reason: str = "self_service_account_deactivate", + ) -> Dict[str, Any]: + identity = self.repository.get_auth_identity(actor_id) + if identity.get("status") == "deactivated": + security = self._serialize_security(actor_id=identity["actor_id"], account_id=identity.get("account_id")) + return { + "status": "deactivated", + "identity": { + "actor_id": identity["actor_id"], + "account_id": identity.get("account_id"), + "actor_role": identity["actor_role"], + "display_name": identity.get("display_name"), + **security, + }, + "revoked_sessions": [], + } + updated_identity = self.repository.save_auth_identity( + { + **identity, + "status": "deactivated", + } + ) + security = self._security_profile(actor_id=updated_identity["actor_id"], account_id=updated_identity.get("account_id")) + updated_profile = self.repository.save_auth_identity_profile( + { + **security, + "actor_id": updated_identity["actor_id"], + "account_id": updated_identity.get("account_id"), + "deactivated_at": self._utcnow(), + "deactivated_by": str(requested_by or updated_identity["actor_id"]).strip() or updated_identity["actor_id"], + "deactivation_reason": reason, + } + ) + revoked_sessions = self.repository.revoke_auth_tokens_for_actor(updated_identity["actor_id"], reason="account_deactivated") + for flow_type in ["auth_refresh", "email_verification", "password_reset", "admin_view_bridge"]: + self.repository.update_auth_flow_tokens_for_actor( + actor_id=updated_identity["actor_id"], + flow_type=flow_type, + statuses=["active"], + updates={"status": "revoked", "consumed_at": self._utcnow()}, + ) + return { + "status": "deactivated", + "identity": { + "actor_id": updated_identity["actor_id"], + "account_id": updated_identity.get("account_id"), + "actor_role": updated_identity["actor_role"], + "display_name": updated_identity.get("display_name"), + **self._serialize_security(actor_id=updated_identity["actor_id"], account_id=updated_identity.get("account_id")), + }, + "profile": updated_profile, + "revoked_sessions": revoked_sessions, + } + + def request_email_change( + self, + *, + actor_id: str, + current_password: str, + new_email: str, + ) -> Dict[str, Any]: + identity = self.authenticate_identity(actor_id=actor_id, password=current_password) + normalized_new_email = str(new_email or "").strip().lower() + if not self._looks_like_email(normalized_new_email): + raise ValueError("email_change_invalid_email") + security = self._serialize_security(actor_id=identity["actor_id"], account_id=identity.get("account_id")) + current_email = str(self._current_email_address(identity=identity, security=security) or "").strip().lower() + if normalized_new_email == current_email: + raise ValueError("email_change_same_as_current") + existing_profile = self.repository.get_auth_identity_profile_by_email_address(normalized_new_email, default=None) + if existing_profile is not None and str(existing_profile.get("actor_id") or "") != identity["actor_id"]: + raise ValueError("email_change_email_already_in_use") + existing_identity = self.repository.get_auth_identity_by_account_id(normalized_new_email, default=None) + if existing_identity is not None and str(existing_identity.get("actor_id") or "") != identity["actor_id"]: + raise ValueError("email_change_email_already_in_use") + pending_profile = self.repository.get_auth_identity_profile_by_email_address(normalized_new_email, pending=True, default=None) + if pending_profile is not None and str(pending_profile.get("actor_id") or "") != identity["actor_id"]: + raise ValueError("email_change_email_pending_elsewhere") + try: + self.email_service.auth_preflight(to_email=normalized_new_email, flow_type="email_change") + except EmailDeliveryError as exc: + self._record_delivery_attempt( + identity=identity, + flow_type="email_change", + recipient_email=normalized_new_email, + status="blocked", + error=exc, + ) + raise AuthServiceError( + http_status=403 if exc.reason == "test_mode_external_recipient_blocked" else 503, + code="auth_email_change_delivery_failed", + reason=exc.reason, + stage="email_policy", + retryable=exc.retryable, + action_hint=exc.action_hint, + identity={ + "actor_id": identity["actor_id"], + "account_id": identity.get("account_id"), + "actor_role": identity["actor_role"], + "display_name": identity.get("display_name"), + **security, + }, + email_provider_status=exc.provider_status, + ) from exc + self._supersede_flow_tokens(actor_id=identity["actor_id"], flow_type="email_change") + change = self._issue_flow_token( + actor_id=identity["actor_id"], + account_id=identity.get("account_id"), + flow_type="email_change", + ttl_seconds=self._verification_token_ttl_seconds(), + payload_json={"new_email": normalized_new_email}, + ) + try: + delivery = self._send_email_change_email( + identity=identity, + new_email=normalized_new_email, + email_change_token=change["token"], + ) + except EmailDeliveryError as exc: + raise AuthServiceError( + http_status=503 if exc.reason != "test_mode_external_recipient_blocked" else 403, + code="auth_email_change_delivery_failed", + reason=exc.reason, + stage="email_delivery", + retryable=exc.retryable, + action_hint=exc.action_hint, + can_retry_send=True, + identity={ + "actor_id": identity["actor_id"], + "account_id": identity.get("account_id"), + "actor_role": identity["actor_role"], + "display_name": identity.get("display_name"), + **security, + }, + email_provider_status=exc.provider_status, + ) from exc + updated_profile = self.repository.save_auth_identity_profile( + { + **self._security_profile(actor_id=identity["actor_id"], account_id=identity.get("account_id")), + "pending_email_address": normalized_new_email, + "pending_email_change_requested_at": self._utcnow(), + "email_change_last_sent_at": self._utcnow(), + } + ) + return { + "status": "email_change_requested", + "pending_email_address": normalized_new_email, + "email_change": self._public_flow_token(change), + "delivery": delivery, + "identity": { + "actor_id": identity["actor_id"], + "account_id": identity.get("account_id"), + "actor_role": identity["actor_role"], + "display_name": identity.get("display_name"), + **self._serialize_security(actor_id=identity["actor_id"], account_id=identity.get("account_id")), + }, + "profile": updated_profile, + } + + def confirm_email_change(self, *, token: str) -> Dict[str, Any]: + resolved = self._resolve_active_flow_token(raw_token=token, flow_type="email_change") + identity = self.repository.get_auth_identity(resolved["actor_id"]) + security = self._security_profile(actor_id=identity["actor_id"], account_id=identity.get("account_id")) + normalized_new_email = str((resolved.get("payload_json") or {}).get("new_email") or security.get("pending_email_address") or "").strip().lower() + if not self._looks_like_email(normalized_new_email): + raise ValueError("email_change_invalid_email") + existing_profile = self.repository.get_auth_identity_profile_by_email_address(normalized_new_email, default=None) + if existing_profile is not None and str(existing_profile.get("actor_id") or "") != identity["actor_id"]: + raise ValueError("email_change_email_already_in_use") + existing_identity = self.repository.get_auth_identity_by_account_id(normalized_new_email, default=None) + if existing_identity is not None and str(existing_identity.get("actor_id") or "") != identity["actor_id"]: + raise ValueError("email_change_email_already_in_use") + updated_identity = self.repository.save_auth_identity( + { + **identity, + "account_id": normalized_new_email, + "status": identity.get("status", "active"), + } + ) + updated_profile = self.repository.save_auth_identity_profile( + { + **security, + "actor_id": updated_identity["actor_id"], + "account_id": normalized_new_email, + "email_address": normalized_new_email, + "pending_email_address": None, + "email_verified": True, + "verification_required": False, + "verified_at": self._utcnow(), + "pending_email_change_requested_at": None, + "email_change_last_sent_at": None, + } + ) + revoked_sessions = self.repository.revoke_auth_tokens_for_actor(updated_identity["actor_id"], reason="email_change") + self._revoke_refresh_tokens_for_actor(updated_identity["actor_id"], status="revoked") + self.repository.update_auth_flow_token( + resolved["flow_token_id"], + {"status": "consumed", "consumed_at": self._utcnow(), "account_id": normalized_new_email}, + ) + self._supersede_flow_tokens( + actor_id=updated_identity["actor_id"], + flow_type="email_change", + exclude_flow_token_id=resolved["flow_token_id"], + ) + self.repository.update_auth_flow_tokens_for_actor( + actor_id=updated_identity["actor_id"], + flow_type="password_reset", + statuses=["active"], + updates={"status": "revoked", "consumed_at": self._utcnow()}, + ) + return { + "status": "email_change_confirmed", + "identity": { + "actor_id": updated_identity["actor_id"], + "account_id": updated_identity.get("account_id"), + "actor_role": updated_identity["actor_role"], + "display_name": updated_identity.get("display_name"), + **self._serialize_security(actor_id=updated_identity["actor_id"], account_id=updated_identity.get("account_id")), + }, + "profile": updated_profile, + "revoked_sessions": revoked_sessions, + } + + def request_email_verification(self, *, actor_id: str) -> Dict[str, Any]: + identity = self.repository.get_auth_identity(actor_id) + security = self._serialize_security(actor_id=identity["actor_id"], account_id=identity.get("account_id")) + if not security.get("verification_required"): + raise ValueError("email_verification_not_applicable") + if security.get("email_verified"): + return { + "status": "already_verified", + "identity": { + "actor_id": identity["actor_id"], + "account_id": identity.get("account_id"), + **security, + }, + } + next_allowed_at = security.get("verification_next_allowed_at") + if not security.get("verification_can_resend", True): + raise AuthServiceError( + http_status=429, + code="auth_verification_invalid", + reason="verification_resend_cooldown", + stage="rate_limit", + retryable=True, + action_hint="wait_before_resend", + can_retry_send=True, + next_allowed_at=next_allowed_at, + identity={ + "actor_id": identity["actor_id"], + "account_id": identity.get("account_id"), + **security, + }, + email_provider_status=security.get("email_provider_status"), + ) + self._supersede_flow_tokens(actor_id=identity["actor_id"], flow_type="email_verification") + verification = self._issue_flow_token( + actor_id=identity["actor_id"], + account_id=identity.get("account_id"), + flow_type="email_verification", + ttl_seconds=self._verification_token_ttl_seconds(), + ) + try: + delivery = self._send_verification_email(identity=identity, verification_token=verification["token"]) + except EmailDeliveryError as exc: + raise AuthServiceError( + http_status=503 if exc.reason != "test_mode_external_recipient_blocked" else 403, + code="auth_verification_delivery_failed", + reason=exc.reason, + stage="email_delivery", + retryable=exc.retryable, + action_hint=exc.action_hint, + can_retry_send=True, + identity={ + "actor_id": identity["actor_id"], + "account_id": identity.get("account_id"), + **security, + }, + email_provider_status=exc.provider_status, + ) from exc + return { + "status": "verification_sent", + "verification": self._public_flow_token(verification), + "delivery": delivery, + "identity": { + "actor_id": identity["actor_id"], + "account_id": identity.get("account_id"), + **self._serialize_security(actor_id=identity["actor_id"], account_id=identity.get("account_id")), + }, + } + + def confirm_email_verification(self, *, token: str) -> Dict[str, Any]: + resolved = self._resolve_active_flow_token(raw_token=token, flow_type="email_verification") + identity = self.repository.get_auth_identity(resolved["actor_id"]) + updated_profile = self.repository.save_auth_identity_profile( + { + **self._security_profile(actor_id=identity["actor_id"], account_id=identity.get("account_id")), + "email_address": identity["actor_id"] if self._looks_like_email(identity["actor_id"]) else None, + "email_verified": True, + "verification_required": False, + "verified_at": self._utcnow(), + } + ) + self.repository.update_auth_flow_token( + resolved["flow_token_id"], + { + "status": "consumed", + "consumed_at": self._utcnow(), + }, + ) + self._supersede_flow_tokens( + actor_id=identity["actor_id"], + flow_type="email_verification", + exclude_flow_token_id=resolved["flow_token_id"], + ) + return { + "status": "verified", + "identity": { + "actor_id": identity["actor_id"], + "account_id": identity.get("account_id"), + "actor_role": identity["actor_role"], + "display_name": identity.get("display_name"), + **self._serialize_security(actor_id=identity["actor_id"], account_id=identity.get("account_id")), + }, + "profile": updated_profile, + } + + def request_password_reset(self, *, actor_id: str) -> Dict[str, Any]: + identity = self.repository.get_auth_identity(actor_id) + security = self._serialize_security(actor_id=identity["actor_id"], account_id=identity.get("account_id")) + if not self._looks_like_email(self._current_email_address(identity=identity, security=security)): + raise ValueError("password_reset_not_applicable") + self._supersede_flow_tokens(actor_id=identity["actor_id"], flow_type="password_reset") + reset = self._issue_flow_token( + actor_id=identity["actor_id"], + account_id=identity.get("account_id"), + flow_type="password_reset", + ttl_seconds=self._password_reset_token_ttl_seconds(), + ) + try: + delivery = self._send_password_reset_email(identity=identity, reset_token=reset["token"]) + except EmailDeliveryError as exc: + raise AuthServiceError( + http_status=503 if exc.reason != "test_mode_external_recipient_blocked" else 403, + code="auth_password_reset_delivery_failed", + reason=exc.reason, + stage="email_delivery", + retryable=exc.retryable, + action_hint=exc.action_hint, + can_retry_send=True, + identity={ + "actor_id": identity["actor_id"], + "account_id": identity.get("account_id"), + **security, + }, + email_provider_status=exc.provider_status, + ) from exc + return { + "status": "password_reset_sent", + "reset": self._public_flow_token(reset), + "delivery": delivery, + "identity": { + "actor_id": identity["actor_id"], + "account_id": identity.get("account_id"), + **security, + }, + } + + def confirm_password_reset(self, *, token: str, new_password: str) -> Dict[str, Any]: + if not str(new_password or "").strip(): + raise ValueError("password_required") + resolved = self._resolve_active_flow_token(raw_token=token, flow_type="password_reset") + identity = self.repository.get_auth_identity(resolved["actor_id"]) + salt = self._password_salt() + updated_identity = self.repository.save_auth_identity( + { + **identity, + "password_hash": self._password_hash(new_password, salt), + "password_salt": salt, + "status": identity.get("status", "active"), + } + ) + revoked_sessions = self.repository.revoke_auth_tokens_for_actor(updated_identity["actor_id"], reason="password_reset") + self._revoke_refresh_tokens_for_actor(updated_identity["actor_id"], status="revoked") + self.repository.update_auth_flow_token( + resolved["flow_token_id"], + { + "status": "consumed", + "consumed_at": self._utcnow(), + }, + ) + self._supersede_flow_tokens( + actor_id=updated_identity["actor_id"], + flow_type="password_reset", + exclude_flow_token_id=resolved["flow_token_id"], + ) + return { + "status": "password_reset_confirmed", + "identity": { + "actor_id": updated_identity["actor_id"], + "account_id": updated_identity.get("account_id"), + "actor_role": updated_identity["actor_role"], + "display_name": updated_identity.get("display_name"), + **self._serialize_security(actor_id=updated_identity["actor_id"], account_id=updated_identity.get("account_id")), + }, + "revoked_sessions": revoked_sessions, + } diff --git a/src/narrativeos/services/author_permissions.py b/src/narrativeos/services/author_permissions.py new file mode 100644 index 0000000..705d597 --- /dev/null +++ b/src/narrativeos/services/author_permissions.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from dataclasses import dataclass +import re +from typing import Iterable, Optional + + +AUTHOR_COLLABORATION_ROLES = frozenset({"author", "reviewer", "ops", "admin", "editor"}) +AUTHOR_REVIEW_ROLES = frozenset({"reviewer", "ops", "admin", "editor"}) + + +@dataclass(frozen=True) +class AuthorPermissionRule: + methods: frozenset[str] + pattern: re.Pattern[str] + allowed_roles: frozenset[str] + require_bearer: bool + missing_reason: str + forbidden_reason: str + + def matches(self, *, method: str, path: str) -> bool: + return method.upper() in self.methods and bool(self.pattern.match(path)) + + +class AuthorPermissionPolicyService: + def __init__(self) -> None: + self._rules: tuple[AuthorPermissionRule, ...] = ( + AuthorPermissionRule( + methods=frozenset({"GET"}), + pattern=re.compile(r"^/v1/author/reviewer-inbox$"), + allowed_roles=AUTHOR_REVIEW_ROLES, + require_bearer=True, + missing_reason="author_review_session_required", + forbidden_reason="author_review_role_forbidden", + ), + AuthorPermissionRule( + methods=frozenset({"POST"}), + pattern=re.compile(r"^/v1/author/drafts/[^/]+/approval/decision$"), + allowed_roles=AUTHOR_REVIEW_ROLES, + require_bearer=True, + missing_reason="author_review_session_required", + forbidden_reason="author_review_role_forbidden", + ), + AuthorPermissionRule( + methods=frozenset({"GET"}), + pattern=re.compile(r"^/v1/author/drafts/[^/]+/collaboration$"), + allowed_roles=AUTHOR_COLLABORATION_ROLES, + require_bearer=True, + missing_reason="author_collaboration_session_required", + forbidden_reason="author_collaboration_role_forbidden", + ), + AuthorPermissionRule( + methods=frozenset({"POST"}), + pattern=re.compile(r"^/v1/author/drafts/[^/]+/comments$"), + allowed_roles=AUTHOR_COLLABORATION_ROLES, + require_bearer=True, + missing_reason="author_collaboration_session_required", + forbidden_reason="author_collaboration_role_forbidden", + ), + AuthorPermissionRule( + methods=frozenset({"POST"}), + pattern=re.compile(r"^/v1/author/comments/[^/]+/reply$"), + allowed_roles=AUTHOR_COLLABORATION_ROLES, + require_bearer=True, + missing_reason="author_collaboration_session_required", + forbidden_reason="author_collaboration_role_forbidden", + ), + AuthorPermissionRule( + methods=frozenset({"POST"}), + pattern=re.compile(r"^/v1/author/comments/[^/]+/status$"), + allowed_roles=AUTHOR_COLLABORATION_ROLES, + require_bearer=True, + missing_reason="author_collaboration_session_required", + forbidden_reason="author_collaboration_role_forbidden", + ), + AuthorPermissionRule( + methods=frozenset({"POST"}), + pattern=re.compile(r"^/v1/author/drafts/[^/]+/approval/request$"), + allowed_roles=AUTHOR_COLLABORATION_ROLES, + require_bearer=True, + missing_reason="author_collaboration_session_required", + forbidden_reason="author_collaboration_role_forbidden", + ), + AuthorPermissionRule( + methods=frozenset({"POST"}), + pattern=re.compile(r"^/v1/author/notifications/[^/]+/status$"), + allowed_roles=AUTHOR_COLLABORATION_ROLES, + require_bearer=True, + missing_reason="author_collaboration_session_required", + forbidden_reason="author_collaboration_role_forbidden", + ), + AuthorPermissionRule( + methods=frozenset({"POST"}), + pattern=re.compile(r"^/v1/author/notifications/bulk-status$"), + allowed_roles=AUTHOR_COLLABORATION_ROLES, + require_bearer=True, + missing_reason="author_collaboration_session_required", + forbidden_reason="author_collaboration_role_forbidden", + ), + AuthorPermissionRule( + methods=frozenset({"POST"}), + pattern=re.compile(r"^/v1/author/comments/[^/]+/watchers$"), + allowed_roles=AUTHOR_COLLABORATION_ROLES, + require_bearer=True, + missing_reason="author_collaboration_session_required", + forbidden_reason="author_collaboration_role_forbidden", + ), + AuthorPermissionRule( + methods=frozenset({"POST"}), + pattern=re.compile(r"^/v1/author/comments/[^/]+/watchers/[^/]+/remove$"), + allowed_roles=AUTHOR_COLLABORATION_ROLES, + require_bearer=True, + missing_reason="author_collaboration_session_required", + forbidden_reason="author_collaboration_role_forbidden", + ), + AuthorPermissionRule( + methods=frozenset({"POST"}), + pattern=re.compile(r"^/v1/author/drafts/[^/]+/watchers$"), + allowed_roles=AUTHOR_COLLABORATION_ROLES, + require_bearer=True, + missing_reason="author_collaboration_session_required", + forbidden_reason="author_collaboration_role_forbidden", + ), + AuthorPermissionRule( + methods=frozenset({"POST"}), + pattern=re.compile(r"^/v1/author/drafts/[^/]+/watchers/[^/]+/remove$"), + allowed_roles=AUTHOR_COLLABORATION_ROLES, + require_bearer=True, + missing_reason="author_collaboration_session_required", + forbidden_reason="author_collaboration_role_forbidden", + ), + AuthorPermissionRule( + methods=frozenset({"GET", "POST"}), + pattern=re.compile(r"^/v1/author/notification-preferences$"), + allowed_roles=AUTHOR_COLLABORATION_ROLES, + require_bearer=True, + missing_reason="author_collaboration_session_required", + forbidden_reason="author_collaboration_role_forbidden", + ), + ) + + def _normalize_role(self, actor_role: Optional[str]) -> str: + return str(actor_role or "").strip() + + def resolve_rule(self, *, method: str, path: str) -> Optional[AuthorPermissionRule]: + normalized_method = method.upper() + normalized_path = path.rstrip("/") or path + for rule in self._rules: + if rule.matches(method=normalized_method, path=normalized_path): + return rule + return None + + def authorize( + self, + *, + actor_id: Optional[str], + actor_role: Optional[str], + identity_source: Optional[str], + method: str, + path: str, + ) -> Optional[dict[str, Optional[str]]]: + rule = self.resolve_rule(method=method, path=path) + if rule is None: + return None + resolved_actor_id = str(actor_id or "").strip() + resolved_actor_role = self._normalize_role(actor_role) + if rule.require_bearer and identity_source not in {"bearer", "cookie"}: + raise PermissionError(rule.missing_reason) + if not resolved_actor_id: + raise PermissionError(rule.missing_reason) + if resolved_actor_role not in set(rule.allowed_roles): + raise PermissionError(rule.forbidden_reason) + return { + "actor_id": resolved_actor_id, + "actor_role": resolved_actor_role, + "identity_source": identity_source, + } diff --git a/src/narrativeos/services/author_project_graph.py b/src/narrativeos/services/author_project_graph.py new file mode 100644 index 0000000..7684c5f --- /dev/null +++ b/src/narrativeos/services/author_project_graph.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +import copy +import json +from typing import Any, Dict, List, Optional +from uuid import uuid4 + +from ..persistence.repositories import SQLAlchemyPlatformRepository +from .authoring import AuthoringService + + +QUANTUM_STUDIO_ENGINES = ["balanced", "effect", "speed", "cost"] + + +class AuthorProjectGraphService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + authoring_service: AuthoringService, + ) -> None: + self.repository = repository + self.authoring = authoring_service + + def _title(self, worldpack: Dict[str, Any], project_id: str) -> str: + return str(worldpack.get("title") or project_id) + + def _quantum_frontend_metadata(self, worldpack: Dict[str, Any]) -> Dict[str, Any]: + metadata = dict(worldpack.get("metadata") or {}) + return dict(metadata.get("quantum_frontend") or {}) + + def _engine(self, worldpack: Dict[str, Any]) -> str: + stored = str(self._quantum_frontend_metadata(worldpack).get("engine") or "").strip() + return stored if stored in QUANTUM_STUDIO_ENGINES else "balanced" + + def _world_rule_specs(self, worldpack: Dict[str, Any]) -> List[Dict[str, Any]]: + world_bible = dict(worldpack.get("world_bible") or {}) + characters = list(worldpack.get("characters") or []) + arc_plans = list(worldpack.get("arc_plans") or []) + locations = list(world_bible.get("locations") or []) + return [ + {"id": "rule_premise", "name": "核心设定", "default_enabled": bool(str(world_bible.get("premise") or "").strip())}, + {"id": "rule_characters", "name": "角色阵列", "default_enabled": bool(characters)}, + {"id": "rule_arc_plan", "name": "章节弧线", "default_enabled": bool(arc_plans)}, + {"id": "rule_locations", "name": "地点锚定", "default_enabled": bool(locations)}, + ] + + def _world_rules(self, worldpack: Dict[str, Any], enabled_rule_ids: Optional[List[str]]) -> List[Dict[str, Any]]: + if enabled_rule_ids is not None: + enabled_set = {str(item).strip() for item in enabled_rule_ids if str(item).strip()} + return [ + {"id": item["id"], "name": item["name"], "enabled": item["id"] in enabled_set} + for item in self._world_rule_specs(worldpack) + ] + return [ + {"id": item["id"], "name": item["name"], "enabled": bool(item["default_enabled"])} + for item in self._world_rule_specs(worldpack) + ] + + def _characters(self, worldpack: Dict[str, Any]) -> List[Dict[str, Any]]: + characters = [] + for index, item in enumerate(list(worldpack.get("characters") or []), start=1): + payload = dict(item or {}) + character_id = str( + payload.get("character_id") + or payload.get("id") + or payload.get("display_name") + or payload.get("name") + or f"character_{index}" + ).strip() + display_name = str( + payload.get("display_name") + or payload.get("name") + or payload.get("character_id") + or f"角色 {index}" + ).strip() + characters.append({"id": character_id, "name": display_name, "avatar": ""}) + return characters + + def _seed_arc_graph(self, worldpack: Dict[str, Any], *, project_id: str) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]], str]: + world_bible = dict(worldpack.get("world_bible") or {}) + volume_order_map = { + str(item.get("volume_id") or ""): int(item.get("order") or 0) + for item in list(worldpack.get("volume_plans") or []) + if str(item.get("volume_id") or "").strip() + } + arc_plans = sorted( + [dict(item or {}) for item in list(worldpack.get("arc_plans") or [])], + key=lambda item: ( + volume_order_map.get(str(item.get("volume_id") or ""), 10_000), + int(item.get("order") or 0), + str(item.get("arc_id") or ""), + ), + )[:12] + nodes: List[Dict[str, Any]] = [ + { + "id": project_id, + "title": self._title(worldpack, project_id), + "type": "root", + "x": 380, + "y": 72, + "description": str(world_bible.get("premise") or ""), + "status": "active", + } + ] + connections: List[Dict[str, Any]] = [] + for index, arc in enumerate(arc_plans): + arc_id = str(arc.get("arc_id") or f"arc_{index + 1}").strip() + first_task = dict((list(arc.get("chapter_tasks") or [{}]) or [{}])[0] or {}) + description = str( + first_task.get("objective") + or first_task.get("notes") + or arc.get("title") + or arc.get("completion_conditions") + or "" + ).strip() + nodes.append( + { + "id": arc_id, + "title": str(arc.get("title") or f"章节弧线 {index + 1}").strip(), + "type": "branch", + "x": 120 + (index % 3) * 260, + "y": 260 + (index // 3) * 210, + "description": description, + "status": "active", + } + ) + connections.append( + { + "from": project_id, + "to": arc_id, + "label": str(arc.get("volume_id") or ""), + } + ) + return nodes, connections, "arc_plan_projection" + + def _seed_graph_from_worldpack(self, worldpack: Dict[str, Any], *, project_id: str) -> Dict[str, Any]: + quantum_frontend = self._quantum_frontend_metadata(worldpack) + stored_nodes = list(quantum_frontend.get("nodes") or []) + stored_connections = list(quantum_frontend.get("connections") or []) + if stored_nodes: + nodes = [] + for index, item in enumerate(stored_nodes): + payload = dict(item or {}) + node_id = str(payload.get("id") or f"node_{index + 1}").strip() + if not node_id: + continue + nodes.append( + { + "id": node_id, + "title": str(payload.get("title") or node_id), + "type": str(payload.get("type") or "branch"), + "x": int(payload.get("x") or 0), + "y": int(payload.get("y") or 0), + "description": str(payload.get("description") or ""), + "status": str(payload.get("status") or "active"), + } + ) + connections = [ + { + "from": str(dict(item or {}).get("from") or ""), + "to": str(dict(item or {}).get("to") or ""), + "label": str(dict(item or {}).get("label") or ""), + } + for item in stored_connections + if str(dict(item or {}).get("from") or "").strip() and str(dict(item or {}).get("to") or "").strip() + ] + if nodes: + return { + "engine": self._engine(worldpack), + "enabled_rule_ids": [item["id"] for item in self._world_rules(worldpack, quantum_frontend.get("enabled_rule_ids")) if item["enabled"]], + "nodes": nodes, + "connections": connections, + "metadata_json": {"graph_source": "worldpack_quantum_frontend"}, + } + nodes, connections, source = self._seed_arc_graph(worldpack, project_id=project_id) + return { + "engine": self._engine(worldpack), + "enabled_rule_ids": [item["id"] for item in self._world_rules(worldpack, None) if item["enabled"]], + "nodes": nodes, + "connections": connections, + "metadata_json": {"graph_source": source}, + } + + def _mirror_into_worldpack( + self, + worldpack: Dict[str, Any], + *, + engine: str, + enabled_rule_ids: List[str], + nodes: List[Dict[str, Any]], + connections: List[Dict[str, Any]], + ) -> Dict[str, Any]: + next_worldpack = copy.deepcopy(worldpack) + metadata = dict(next_worldpack.get("metadata") or {}) + quantum_frontend = dict(metadata.get("quantum_frontend") or {}) + quantum_frontend["engine"] = engine + quantum_frontend["enabled_rule_ids"] = list(enabled_rule_ids) + quantum_frontend["nodes"] = list(nodes) + quantum_frontend["connections"] = list(connections) + metadata["quantum_frontend"] = quantum_frontend + next_worldpack["metadata"] = metadata + return next_worldpack + + def _persist_graph_and_mirror( + self, + *, + project_id: str, + draft_detail: Dict[str, Any], + engine: str, + enabled_rule_ids: List[str], + nodes: List[Dict[str, Any]], + connections: List[Dict[str, Any]], + change_context: Dict[str, Any], + metadata_json: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + worldpack = dict(draft_detail.get("worldpack") or {}) + mirrored_worldpack = self._mirror_into_worldpack( + worldpack, + engine=engine, + enabled_rule_ids=enabled_rule_ids, + nodes=nodes, + connections=connections, + ) + updated_draft = self.authoring.update_draft(project_id, mirrored_worldpack, change_context=change_context) + self.repository.save_author_project_graph( + { + "project_id": project_id, + "world_version_id": project_id, + "account_id": str(((updated_draft.get("worldpack") or {}).get("manifest") or {}).get("author_id") or ""), + "engine": engine, + "enabled_rule_ids": enabled_rule_ids, + "nodes": nodes, + "connections": connections, + "metadata_json": dict(metadata_json or {}), + } + ) + return updated_draft + + def _graph_row(self, *, project_id: str, draft_detail: Dict[str, Any]) -> Dict[str, Any]: + row = self.repository.get_author_project_graph(project_id, default=None) + if row is not None: + return row + seed = self._seed_graph_from_worldpack(dict(draft_detail.get("worldpack") or {}), project_id=project_id) + return self.repository.save_author_project_graph( + { + "project_id": project_id, + "world_version_id": project_id, + "account_id": str(((draft_detail.get("worldpack") or {}).get("manifest") or {}).get("author_id") or ""), + **seed, + } + ) + + def project_payload(self, *, project_id: str, draft_detail: Dict[str, Any]) -> Dict[str, Any]: + worldpack = dict(draft_detail.get("worldpack") or {}) + graph = self._graph_row(project_id=project_id, draft_detail=draft_detail) + return { + "id": project_id, + "title": self._title(worldpack, project_id), + "engine": str(graph.get("engine") or self._engine(worldpack)), + "availableEngines": list(QUANTUM_STUDIO_ENGINES), + "worldRules": self._world_rules(worldpack, list(graph.get("enabled_rule_ids") or [])), + "characters": self._characters(worldpack), + "nodes": list(graph.get("nodes") or []), + "connections": list(graph.get("connections") or []), + } + + def set_engine(self, *, project_id: str, draft_detail: Dict[str, Any], engine: str) -> Dict[str, Any]: + graph = self._graph_row(project_id=project_id, draft_detail=draft_detail) + updated = self._persist_graph_and_mirror( + project_id=project_id, + draft_detail=draft_detail, + engine=engine, + enabled_rule_ids=list(graph.get("enabled_rule_ids") or []), + nodes=list(graph.get("nodes") or []), + connections=list(graph.get("connections") or []), + change_context={"source": "author_project_graph_set_engine", "label": "Quantum Studio set engine"}, + metadata_json={**dict(graph.get("metadata_json") or {}), "graph_source": "author_project_graph"}, + ) + return self.project_payload(project_id=project_id, draft_detail=updated) + + def set_world_rules(self, *, project_id: str, draft_detail: Dict[str, Any], rule_ids: List[str]) -> Dict[str, Any]: + graph = self._graph_row(project_id=project_id, draft_detail=draft_detail) + updated = self._persist_graph_and_mirror( + project_id=project_id, + draft_detail=draft_detail, + engine=str(graph.get("engine") or self._engine(dict(draft_detail.get("worldpack") or {}))), + enabled_rule_ids=sorted(set(rule_ids)), + nodes=list(graph.get("nodes") or []), + connections=list(graph.get("connections") or []), + change_context={"source": "author_project_graph_set_rules", "label": "Quantum Studio update world rules"}, + metadata_json={**dict(graph.get("metadata_json") or {}), "graph_source": "author_project_graph"}, + ) + return self.project_payload(project_id=project_id, draft_detail=updated) + + def add_node(self, *, project_id: str, draft_detail: Dict[str, Any], payload: Dict[str, Any]) -> Dict[str, Any]: + graph = self._graph_row(project_id=project_id, draft_detail=draft_detail) + nodes = list(graph.get("nodes") or []) + connections = list(graph.get("connections") or []) + node_id = f"studio_node_{uuid4().hex[:10]}" + nodes.append( + { + "id": node_id, + "title": str(payload.get("title") or "新节点").strip() or "新节点", + "type": str(payload.get("type") or "branch"), + "x": int(payload.get("x") or 0), + "y": int(payload.get("y") or 0), + "description": str(payload.get("description") or ""), + "status": "active", + } + ) + parent_id = str(payload.get("parentId") or project_id).strip() or project_id + if parent_id != node_id and any(str(item.get("id") or "") == parent_id for item in nodes): + connections.append({"from": parent_id, "to": node_id, "label": ""}) + updated = self._persist_graph_and_mirror( + project_id=project_id, + draft_detail=draft_detail, + engine=str(graph.get("engine") or self._engine(dict(draft_detail.get("worldpack") or {}))), + enabled_rule_ids=list(graph.get("enabled_rule_ids") or []), + nodes=nodes, + connections=connections, + change_context={"source": "author_project_graph_add_node", "label": "Quantum Studio add node"}, + metadata_json={**dict(graph.get("metadata_json") or {}), "graph_source": "author_project_graph"}, + ) + return self.project_payload(project_id=project_id, draft_detail=updated) + + def update_node(self, *, project_id: str, draft_detail: Dict[str, Any], node_id: str, payload: Dict[str, Any]) -> Dict[str, Any]: + graph = self._graph_row(project_id=project_id, draft_detail=draft_detail) + nodes = list(graph.get("nodes") or []) + connections = list(graph.get("connections") or []) + target = next((item for item in nodes if str(item.get("id") or "") == node_id), None) + if target is None: + raise KeyError("studio_node_missing") + if payload.get("title") is not None: + target["title"] = str(payload.get("title") or "").strip() or target["title"] + if payload.get("description") is not None: + target["description"] = str(payload.get("description") or "") + if payload.get("x") is not None: + target["x"] = int(payload.get("x")) + if payload.get("y") is not None: + target["y"] = int(payload.get("y")) + updated = self._persist_graph_and_mirror( + project_id=project_id, + draft_detail=draft_detail, + engine=str(graph.get("engine") or self._engine(dict(draft_detail.get("worldpack") or {}))), + enabled_rule_ids=list(graph.get("enabled_rule_ids") or []), + nodes=nodes, + connections=connections, + change_context={"source": "author_project_graph_update_node", "label": "Quantum Studio update node"}, + metadata_json={**dict(graph.get("metadata_json") or {}), "graph_source": "author_project_graph"}, + ) + return self.project_payload(project_id=project_id, draft_detail=updated) + + def export_project(self, *, project: Dict[str, Any], format_value: str) -> str: + normalized = str(format_value or "").strip().lower() + if normalized == "json": + return json.dumps(project, ensure_ascii=False, indent=2) + lines = [ + f"# {project['title']}", + "", + f"- projectId: {project['id']}", + f"- engine: {project['engine']}", + f"- worldRules: {', '.join(item['name'] for item in project.get('worldRules', []) if item.get('enabled')) or '-'}", + "", + "## Characters", + *(f"- {item['name']}" for item in project.get("characters", [])), + "", + "## Nodes", + *(f"- {item['id']}: {item['title']} ({item['type']})" for item in project.get("nodes", [])), + "", + "## Connections", + *(f"- {item['from']} -> {item['to']}" + (f" [{item['label']}]" if item.get("label") else "") for item in project.get("connections", [])), + ] + return "\n".join(lines).strip() + "\n" diff --git a/src/narrativeos/services/author_work.py b/src/narrativeos/services/author_work.py new file mode 100644 index 0000000..dfd65a5 --- /dev/null +++ b/src/narrativeos/services/author_work.py @@ -0,0 +1,1311 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional +from uuid import uuid4 + +from ..core.linter import lint_chapter_draft, story_text_unit_count +from ..eval.service import ( + CHAPTER_QUALITY_GUARD_FAILURE_CODE, + ChapterQualityGuardError, + apply_quality_gate_to_report, + evaluate_chapter, + evaluate_persisted_chapter, +) +from ..eval.reporting import aggregate_reports +from ..eval.taxonomy import ISSUE_TAXONOMY +from ..models import EvaluationReport +from ..longform import apply_steering_directive, configure_interactive_longform_runtime, configure_longform_runtime +from ..models import NarrativeState +from ..pipeline import plan_next_turn +from ..providers import StaticCandidateProvider +from ..quality.adapter import persist_guardrail_records +from ..rendering import TemplateRenderer +from ..sanitizer import sanitize_reader_visible_payload +from ..worldpacks.registry import FileSystemWorldRegistry +from .authoring import ( + AuthoringService, + _default_memory_compression_policy, + _default_steering_guardrails, + _resolve_longform_structure, +) +from .choice_semantics import build_choice_impacts +from .provider_routing import ProviderRoutingService +from ..persistence.repositories import SQLAlchemyPlatformRepository +from .analytics import AnalyticsService + + +VALID_AUTHOR_WORK_STATUSES = {"draft", "review_ready", "submitted", "approved", "needs_changes"} +VALID_AUTHOR_WORK_CHAPTER_STATUSES = {"generated", "edited", "needs_review"} +VALID_AUTHOR_WORK_GENERATION_MODES = {"first", "next", "arc"} + + +def _trim_generated_author_work_body( + body: str, + *, + min_units: int = 1800, + max_units: int = 2200, +) -> str: + paragraphs = [str(item).strip() for item in str(body or "").split("\n\n") if str(item).strip()] + if not paragraphs: + return str(body or "") + current_body = "\n\n".join(paragraphs) + current_units = story_text_unit_count(current_body) + if current_units <= max_units: + return current_body + while len(paragraphs) > 2 and current_units > max_units: + trimmed = False + for index in range(len(paragraphs) - 2, 0, -1): + candidate_paragraphs = paragraphs[:index] + paragraphs[index + 1 :] + candidate_body = "\n\n".join(candidate_paragraphs) + candidate_units = story_text_unit_count(candidate_body) + if candidate_units < min_units: + continue + paragraphs = candidate_paragraphs + current_body = candidate_body + current_units = candidate_units + trimmed = True + if current_units <= max_units: + return current_body + break + if not trimmed: + break + return current_body + + +class AuthorWorkService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + registry: Optional[FileSystemWorldRegistry] = None, + provider_routing_service: Optional[ProviderRoutingService] = None, + analytics_service: Optional[AnalyticsService] = None, + ) -> None: + self.repository = repository + self.registry = registry or FileSystemWorldRegistry() + self.provider_routing = provider_routing_service + self.analytics = analytics_service or AnalyticsService(repository) + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _work_title(self, worldpack: Dict[str, Any], world_version_id: str) -> str: + return str(worldpack.get("title") or worldpack.get("world_id") or world_version_id) + + def _target_chapter_count(self, worldpack: Dict[str, Any]) -> int: + return int(((worldpack.get("series_plan") or {}).get("total_chapter_target")) or 0) + + def _normalized_work_branch(self, work: Dict[str, Any]) -> Dict[str, Any]: + payload = dict(work or {}) + work_id = str(payload.get("work_id") or "") + root_work_id = str(payload.get("root_work_id") or work_id) + return { + **payload, + "branch_id": str(payload.get("branch_id") or work_id), + "root_work_id": root_work_id, + "parent_work_id": payload.get("parent_work_id"), + "branch_name": str(payload.get("branch_name") or ("主线" if root_work_id == work_id else "平行宇宙")), + "branch_kind": str(payload.get("branch_kind") or ("mainline" if root_work_id == work_id else "parallel_universe")), + "branch_origin_label": payload.get("branch_origin_label"), + "fork_after_chapter_index": int(payload.get("fork_after_chapter_index", 0) or 0), + "is_active_line": bool(payload.get("is_active_line", root_work_id == work_id)), + } + + def _ensure_work_branch_metadata(self, work: Dict[str, Any]) -> Dict[str, Any]: + normalized = self._normalized_work_branch(work) + if ( + work.get("branch_id") == normalized["branch_id"] + and work.get("root_work_id") == normalized["root_work_id"] + and work.get("parent_work_id") == normalized["parent_work_id"] + and work.get("branch_name") == normalized["branch_name"] + and work.get("branch_kind") == normalized["branch_kind"] + and work.get("branch_origin_label") == normalized["branch_origin_label"] + and int(work.get("fork_after_chapter_index", 0) or 0) == normalized["fork_after_chapter_index"] + and bool(work.get("is_active_line")) == normalized["is_active_line"] + ): + return normalized + persisted = self.repository.save_author_work({**work, **normalized}) + return self._normalized_work_branch(persisted) + + def _branch_family(self, work: Dict[str, Any]) -> List[Dict[str, Any]]: + normalized = self._normalized_work_branch(work) + family = self.repository.list_author_works(root_work_id=normalized["root_work_id"], limit=100) + ordered = sorted( + [self._normalized_work_branch(item) for item in family], + key=lambda item: ( + 0 if item["branch_kind"] == "mainline" else 1, + 0 if item["is_active_line"] else 1, + item["fork_after_chapter_index"], + item.get("created_at") or "", + ), + ) + return [ + { + "work_id": item["work_id"], + "branch_id": item["branch_id"], + "root_work_id": item["root_work_id"], + "parent_work_id": item.get("parent_work_id"), + "branch_name": item["branch_name"], + "branch_kind": item["branch_kind"], + "branch_origin_label": item.get("branch_origin_label"), + "fork_after_chapter_index": item["fork_after_chapter_index"], + "is_active_line": item["is_active_line"], + "chapter_count": item.get("chapter_count", 0), + "target_chapter_count": item.get("target_chapter_count", 0), + "status": item.get("status"), + "updated_at": item.get("updated_at"), + } + for item in ordered + ] + + def _work_snapshot(self, work: Dict[str, Any], chapters: List[Dict[str, Any]]) -> Dict[str, Any]: + normalized_work = self._normalized_work_branch(work) + return { + "work": { + "work_id": normalized_work.get("work_id"), + "world_version_id": normalized_work.get("world_version_id"), + "account_id": normalized_work.get("account_id"), + "title": normalized_work.get("title"), + "status": normalized_work.get("status"), + "current_revision": normalized_work.get("current_revision"), + "chapter_count": normalized_work.get("chapter_count"), + "target_chapter_count": normalized_work.get("target_chapter_count"), + "branch_id": normalized_work.get("branch_id"), + "root_work_id": normalized_work.get("root_work_id"), + "parent_work_id": normalized_work.get("parent_work_id"), + "branch_name": normalized_work.get("branch_name"), + "branch_kind": normalized_work.get("branch_kind"), + "branch_origin_label": normalized_work.get("branch_origin_label"), + "fork_after_chapter_index": normalized_work.get("fork_after_chapter_index"), + "is_active_line": normalized_work.get("is_active_line"), + "diagnostics_summary_json": dict(normalized_work.get("diagnostics_summary_json") or {}), + }, + "chapters": [ + { + "chapter_index": item.get("chapter_index"), + "chapter_title": item.get("chapter_title"), + "status": item.get("status"), + "source_type": item.get("source_type"), + "summary": item.get("summary"), + } + for item in chapters + ], + } + + def _revision(self, *, work: Dict[str, Any], chapters: List[Dict[str, Any]], revision_type: str, summary: str) -> Dict[str, Any]: + revision = self.repository.save_author_work_revision( + { + "work_id": work["work_id"], + "revision_type": revision_type, + "summary": summary, + "snapshot_json": self._work_snapshot(work, chapters), + } + ) + updated = self.repository.save_author_work( + { + **work, + "current_revision": revision["revision_id"], + } + ) + return updated + + def _quality_gate_min_target_words(self, runtime_context: Dict[str, Any]) -> Optional[int]: + chapter_budget_policy = dict((runtime_context.get("longform_structure") or {}).get("chapter_budget_policy") or {}) + if chapter_budget_policy.get("min_target_words") is None: + return None + return int(chapter_budget_policy.get("min_target_words") or 0) + + def _record_quality_guard_failure( + self, + *, + work: Dict[str, Any], + chapters: List[Dict[str, Any]], + chapter_index: int, + surface: str, + quality_gate: Dict[str, Any], + ) -> None: + self.repository.save_author_work_revision( + { + "work_id": work["work_id"], + "revision_type": "quality_guard_blocked", + "summary": f"章节硬约束拦截 · 第 {chapter_index} 章 · {surface}", + "snapshot_json": { + **self._work_snapshot(work, chapters), + "quality_gate": dict(quality_gate or {}), + "surface": surface, + "chapter_index": chapter_index, + "repair_loop_context": { + "issue_code": str(quality_gate.get("primary_issue_group") or ""), + "issue_label": str( + ISSUE_TAXONOMY.get(str(quality_gate.get("primary_issue_group") or ""), {}).get("label") + or quality_gate.get("primary_issue_group") + or "" + ), + "asset_type": str((quality_gate.get("primary_asset_target") or {}).get("asset_type") or ""), + "asset_label": str((quality_gate.get("primary_asset_target") or {}).get("asset_label") or ""), + "target_label": str((quality_gate.get("primary_asset_target") or {}).get("asset_type") or ""), + "validation_panel": str((quality_gate.get("primary_asset_target") or {}).get("validation_panel") or ""), + "validation_panel_label": str((quality_gate.get("primary_asset_target") or {}).get("validation_panel_label") or ""), + "window_breach_kind": str(quality_gate.get("window_breach_kind") or ""), + "chapter_index": chapter_index, + "targeted_chapter_indices": [chapter_index], + }, + }, + } + ) + + def _runtime_context(self, *, world_version_id: str) -> Dict[str, Any]: + version = self.repository.get_world_version(world_version_id) + runtime = self.repository.get_runtime_bundle(world_version_id) + 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(24, self._target_chapter_count(worldpack_payload) or 24), + ) + return { + "version": version, + "runtime": runtime, + "worldpack": worldpack_payload, + "longform_structure": longform_structure, + } + + def _configured_state(self, *, work: Dict[str, Any], runtime_context: Dict[str, Any]) -> NarrativeState: + runtime = runtime_context["runtime"] + worldpack = runtime_context["worldpack"] + longform = runtime_context["longform_structure"] + state_payload = dict(work.get("narrative_state_json") or {}) + if state_payload: + state = NarrativeState.from_dict(state_payload) + else: + state = NarrativeState.from_dict(runtime.initial_state.to_dict()) + configure_longform_runtime( + state, + series_plan=dict(longform.get("series_plan") or {}), + volume_plans=list(longform.get("volume_plans") or []), + arc_plans=list(longform.get("arc_plans") or []), + chapter_budget_policy=dict(longform.get("chapter_budget_policy") or {}), + memory_compression_policy=dict( + worldpack.get("memory_compression_policy") + or _default_memory_compression_policy(len(longform.get("volume_plans") or [])) + ), + world=runtime.world_record.world, + ) + configure_interactive_longform_runtime( + state, + series_storyline_contract=dict(worldpack.get("series_storyline_contract") or {}), + character_memory_profiles=dict(worldpack.get("character_memory_profiles") or {}), + steering_guardrails={ + **_default_steering_guardrails(), + **dict(worldpack.get("steering_guardrails") or {}), + }, + ) + state.metadata = { + **dict(state.metadata or {}), + "authoring_surface": "author_work_generation", + } + return state + + def _chapter_reports(self, chapters: List[Dict[str, Any]], *, world_version_id: str, work_id: str) -> List[Dict[str, Any]]: + runtime_context = self._runtime_context(world_version_id=world_version_id) + min_target_words = self._quality_gate_min_target_words(runtime_context) + target_chapters = int( + ((runtime_context.get("longform_structure") or {}).get("series_plan") or {}).get("total_chapter_target") + or 0 + ) + reports = [] + for chapter in chapters: + body = str(chapter.get("body") or "") + if not body.strip(): + continue + lint = lint_chapter_draft(body) + state_after_payload = dict(chapter.get("state_snapshot_json") or {}) + if state_after_payload: + coverage_context = dict((state_after_payload.get("metadata") or {}).get("coverage_context") or {}) + state_after = NarrativeState.from_dict(state_after_payload) + else: + coverage_context = {} + state_after = NarrativeState.from_dict( + { + "state_id": f"{work_id}::diagnostic::{chapter['chapter_index']}", + "world_id": world_version_id, + "turn_index": int(chapter.get("chapter_index") or 0), + "story_phase": "setup", + "chapter_index": int(chapter.get("chapter_index") or 0), + "min_end_turn": max(8, int(chapter.get("chapter_index") or 0)), + "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", + } + ) + report_bundle = evaluate_persisted_chapter( + chapter_id=f"{work_id}::{chapter['chapter_index']}", + world_version_id=world_version_id, + session_id=f"author_work:{work_id}", + body=body, + paragraphs=body.split("\n\n"), + dialogue_count=int(lint["dialogue_count"]), + action_count=int(lint["action_count"]), + detail_count=int(lint["detail_count"]), + character_fidelity_score=0.75, + state_after=state_after, + ending_ready=False, + chapter_title=chapter.get("chapter_title"), + choices=list(chapter.get("choices_json") or []), + paywall_required=False, + coverage_context={ + **coverage_context, + "chapter_task": dict(chapter.get("chapter_task_json") or {}), + }, + target_words=(chapter.get("chapter_task_json") or {}).get("target_words"), + min_target_words=min_target_words, + chapter_index=int(chapter.get("chapter_index") or 0), + target_chapters=target_chapters, + story_phase=str(state_after.story_phase or ""), + rolling_quality_window=list((state_after.metadata or {}).get("quality_contract_window", [])), + enforcement_scope="author_work_diagnostics", + ) + reports.append(apply_quality_gate_to_report(report_bundle["report"], report_bundle["quality_gate"])) + return reports + + def list_works(self, *, account_id: Optional[str] = None, world_version_id: Optional[str] = None) -> Dict[str, Any]: + works = self.repository.list_author_works(account_id=account_id, world_version_id=world_version_id) + return { + "works": [self._work_detail_payload(item, include_chapters=False) for item in works], + } + + def list_branches(self, *, work_id: str) -> Dict[str, Any]: + work = self._ensure_work_branch_metadata(self.repository.get_author_work(work_id)) + return { + "work_id": work["work_id"], + "branch_family": self._branch_family(work), + } + + def activate_branch(self, *, work_id: str) -> Dict[str, Any]: + work = self._ensure_work_branch_metadata(self.repository.get_author_work(work_id)) + self.repository.set_author_work_active_line( + root_work_id=work["root_work_id"], + active_work_id=work["work_id"], + ) + return self.get_work(work["work_id"]) + + def delete_work_family(self, *, work_id: str) -> Dict[str, Any]: + work = self._ensure_work_branch_metadata(self.repository.get_author_work(work_id)) + deleted = self.repository.delete_author_work_family(root_work_id=work["root_work_id"] or work["work_id"]) + self.analytics.track( + "author_work_deleted", + reader_id=work.get("account_id"), + account_id=work.get("account_id"), + world_version_id=work.get("world_version_id"), + payload_json={ + "work_id": work_id, + "root_work_id": work.get("root_work_id") or work.get("work_id"), + "deleted_work_count": deleted.get("deleted_work_count"), + }, + ) + return { + **deleted, + "deleted_title": work.get("title") or work.get("branch_name") or work.get("work_id"), + "deleted_root_work_id": work["root_work_id"] or work["work_id"], + } + + def _next_parallel_branch_name(self, *, root_work_id: str) -> str: + family = self.repository.list_author_works(root_work_id=root_work_id, limit=100) + parallel_count = sum( + 1 for item in family if str(item.get("branch_kind") or "") == "parallel_universe" + ) + return f"平行宇宙 {parallel_count + 1}" + + def create_branch( + self, + *, + work_id: str, + source_chapter_index: int, + label: Optional[str] = None, + steering_directive: Optional[Dict[str, Any]] = None, + choice_source: Optional[Any] = None, + ) -> Dict[str, Any]: + source_work = self._ensure_work_branch_metadata(self.repository.get_author_work(work_id)) + chapters = self.repository.list_author_work_chapters(work_id=work_id) + max_chapter_index = max((int(item.get("chapter_index") or 0) for item in chapters), default=0) + fork_after_chapter_index = int(source_chapter_index or 0) + if fork_after_chapter_index < 0: + raise ValueError("invalid_branch_source_chapter_index") + if max_chapter_index and fork_after_chapter_index == 0: + raise ValueError("branch_requires_existing_chapter_context") + if fork_after_chapter_index > max_chapter_index: + raise ValueError("branch_source_chapter_out_of_range") + + runtime_context = self._runtime_context(world_version_id=source_work["world_version_id"]) + if fork_after_chapter_index > 0: + source_chapter = self.repository.get_author_work_chapter(work_id=work_id, chapter_index=fork_after_chapter_index) + branch_state = NarrativeState.from_dict(dict(source_chapter.get("state_snapshot_json") or {})) + else: + branch_state = self._configured_state(work=source_work, runtime_context=runtime_context) + + directive = dict(steering_directive or {}) + if directive: + directive.setdefault("summary", str(label or "").strip() or str(choice_source or "").strip() or "平行宇宙引导") + directive.setdefault("current_user_intent", directive.get("summary")) + apply_steering_directive( + branch_state, + directive, + world=runtime_context["runtime"].world_record.world, + ) + + branch_name = str(label or "").strip() or self._next_parallel_branch_name(root_work_id=source_work["root_work_id"]) + branch_origin_label = str(choice_source or directive.get("summary") or label or "").strip() or None + new_work = self.repository.save_author_work( + { + "world_version_id": source_work["world_version_id"], + "account_id": source_work["account_id"], + "title": source_work["title"], + "status": "draft", + "chapter_count": fork_after_chapter_index, + "target_chapter_count": source_work.get("target_chapter_count", 0), + "branch_id": f"branch_{uuid4().hex[:12]}", + "root_work_id": source_work["root_work_id"], + "parent_work_id": source_work["work_id"], + "branch_name": branch_name, + "branch_kind": "parallel_universe", + "branch_origin_label": branch_origin_label, + "fork_after_chapter_index": fork_after_chapter_index, + "is_active_line": True, + "narrative_state_json": branch_state.to_dict(), + "diagnostics_summary_json": {}, + } + ) + + for chapter in chapters: + if int(chapter.get("chapter_index") or 0) > fork_after_chapter_index: + continue + self.repository.save_author_work_chapter( + { + **chapter, + "chapter_record_id": None, + "work_id": new_work["work_id"], + } + ) + + self.repository.set_author_work_active_line( + root_work_id=source_work["root_work_id"], + active_work_id=new_work["work_id"], + ) + + branch_snapshot = self.repository.get_author_work(new_work["work_id"]) + branch_snapshot = self._ensure_work_branch_metadata(branch_snapshot) + copied_chapters = self.repository.list_author_work_chapters(work_id=new_work["work_id"]) + branch_snapshot = self._revision( + work=branch_snapshot, + chapters=copied_chapters, + revision_type="branch_create", + summary=f"从第 {fork_after_chapter_index} 章后创建 {branch_name}", + ) + self.analytics.track( + "author_work_branch_created", + reader_id=source_work.get("account_id"), + account_id=source_work.get("account_id"), + world_version_id=source_work.get("world_version_id"), + payload_json={ + "work_id": branch_snapshot.get("work_id"), + "root_work_id": source_work.get("root_work_id"), + "parent_work_id": source_work.get("work_id"), + }, + ) + return self.get_work(branch_snapshot["work_id"]) + + def create_work(self, *, world_version_id: str, account_id: str) -> Dict[str, Any]: + existing = self.repository.list_author_works(account_id=account_id, world_version_id=world_version_id, limit=20) + reusable = next( + ( + item + for item in existing + if item.get("status") in {"draft", "review_ready", "needs_changes"} and bool(item.get("is_active_line", True)) + ), + None, + ) or next((item for item in existing if item.get("status") in {"draft", "review_ready", "needs_changes"}), None) + if reusable: + return self.get_work(reusable["work_id"]) + runtime_context = self._runtime_context(world_version_id=world_version_id) + worldpack = runtime_context["worldpack"] + state = self._configured_state( + work={ + "narrative_state_json": dict(runtime_context["runtime"].initial_state.to_dict()), + }, + runtime_context=runtime_context, + ) + work = self.repository.save_author_work( + { + "world_version_id": world_version_id, + "account_id": account_id, + "title": self._work_title(worldpack, world_version_id), + "status": "draft", + "chapter_count": 0, + "target_chapter_count": self._target_chapter_count(worldpack), + "branch_name": "主线", + "branch_kind": "mainline", + "fork_after_chapter_index": 0, + "is_active_line": True, + "narrative_state_json": state.to_dict(), + } + ) + work = self.repository.save_author_work({**work, "root_work_id": work["work_id"], "branch_id": work["work_id"], "is_active_line": True}) + work = self._revision(work=work, chapters=[], revision_type="initialized", summary="初始化作品稿") + self.analytics.track( + "author_work_created", + reader_id=account_id, + account_id=account_id, + world_version_id=world_version_id, + payload_json={ + "work_id": work.get("work_id"), + "root_work_id": work.get("root_work_id") or work.get("work_id"), + }, + ) + return self.get_work(work["work_id"]) + + def _normalized_work_diagnostics(self, diagnostics: Optional[Dict[str, Any]]) -> Dict[str, Any]: + payload = dict(diagnostics or {}) + evaluation_summary = dict(payload.get("evaluation_summary") or {}) + latest_decision = payload.get("latest_decision") + chapter_count = int(payload.get("chapter_count") or 0) + next_actions: List[str] = [] + for item in payload.get("reports") or []: + decision = dict((item or {}).get("decision") or {}) + recommendation = str(decision.get("recommended_action") or "").strip() + if recommendation and recommendation not in next_actions: + next_actions.append(recommendation) + if not next_actions: + next_actions = list(payload.get("next_actions") or evaluation_summary.get("next_actions") or []) + return { + "chapter_count": chapter_count, + "latest_decision": latest_decision, + "evaluation_summary": evaluation_summary, + "next_actions": next_actions, + "raw": payload, + } + + def _aggregate_work_decision(self, aggregated: Dict[str, Any]) -> str: + if float(aggregated.get("block_rate", 0.0) or 0.0) > 0.0: + return "block" + if float(aggregated.get("rewrite_rate", 0.0) or 0.0) > 0.0: + return "rewrite" + return "pass" + + def _normalized_chapter_diagnostics(self, diagnostic_summary: Optional[Dict[str, Any]]) -> Dict[str, Any]: + payload = dict(diagnostic_summary or {}) + decision = dict(payload.get("decision") or {}) + issues = list(payload.get("issues") or []) + issue_codes = [ + str(item.get("issue_code")) + for item in issues + if isinstance(item, dict) and str(item.get("issue_code") or "").strip() + ] + return { + "decision": decision.get("decision"), + "recommended_action": decision.get("recommended_action") or decision.get("reason"), + "issue_count": len(issues), + "issue_codes": issue_codes, + "issues": issues, + "raw": payload, + } + + def _collect_issue_codes(self, *, work: Dict[str, Any], chapters: List[Dict[str, Any]]) -> List[str]: + issue_codes: List[str] = [] + for item in list((work.get("diagnostics_summary_json") or {}).get("reports") or []): + for issue in list((item or {}).get("issues") or []): + code = str((issue or {}).get("issue_code") or "").strip() + if code: + issue_codes.append(code) + for chapter in chapters: + for issue in list((chapter.get("diagnostic_summary_json") or {}).get("issues") or []): + code = str((issue or {}).get("issue_code") or "").strip() + if code: + issue_codes.append(code) + return issue_codes + + def _product_quality_summary(self, *, work: Dict[str, Any], chapters: List[Dict[str, Any]]) -> Dict[str, Any]: + issue_codes = set(self._collect_issue_codes(work=work, chapters=chapters)) + return { + "重复感": "需留意" if "Q03" in issue_codes else "良好", + "场景细节": "需补强" if "Q05" in issue_codes else "充足", + "节奏": "需调整" if "Q09" in issue_codes else "稳定", + "结尾风险": "偏高" if "Q09" in issue_codes else "正常", + } + + def _route_display_name(self, branch: Dict[str, Any], index: int) -> str: + branch_kind = str(branch.get("branch_kind") or "") + if branch_kind == "mainline" or index == 0: + return "主线" + origin = str(branch.get("branch_origin_label") or branch.get("branch_name") or "").strip() + letter = chr(ord("A") + max(0, index - 1)) + return f"路线 {letter}:{origin or '新的走向'}" + + def _branch_map_payload(self, *, work: Dict[str, Any], export_work_id: str) -> List[Dict[str, Any]]: + family = self._branch_family(work) + branch_map: List[Dict[str, Any]] = [] + for index, branch in enumerate(family): + try: + branch_work = self.repository.get_author_work(branch["work_id"]) + branch_chapters = self.repository.list_author_work_chapters(work_id=branch["work_id"]) + issue_codes = set(self._collect_issue_codes(work=branch_work, chapters=branch_chapters)) + except KeyError: + branch_chapters = [] + issue_codes = set() + branch_map.append( + { + "route_name": self._route_display_name(branch, index), + "current_chapter_count": int(branch.get("chapter_count") or len(branch_chapters) or 0), + "recent_choice": branch.get("branch_origin_label") or ("主线推进" if index == 0 else "新的走向"), + "quality_status": "需关注" if issue_codes else "稳定", + "is_export_main_route": bool(branch.get("work_id") == export_work_id), + "fork_after_chapter_index": int(branch.get("fork_after_chapter_index") or 0), + } + ) + return branch_map + + def _work_content_quality_repair_workbench( + self, + *, + work: Dict[str, Any], + chapters: List[Dict[str, Any]], + ) -> Dict[str, Any]: + diagnostic_payloads = [dict(item.get("diagnostic_summary_json") or {}) for item in chapters if dict(item.get("diagnostic_summary_json") or {})] + if not diagnostic_payloads: + return {"available": False, "windows": {}, "default_campaign": {}, "campaigns": [], "next_actions": []} + runtime_context = self._runtime_context(world_version_id=work["world_version_id"]) + worldpack = dict(runtime_context.get("worldpack") or {}) + scene_by_function: Dict[str, Dict[str, Any]] = {} + for scene in list(worldpack.get("scene_blueprints") or []): + payload = dict(scene or {}) + scene_function = str(payload.get("scene_function") or "") + if scene_function and scene_function not in scene_by_function: + scene_by_function[scene_function] = payload + role_to_character_ids: Dict[str, List[str]] = {} + for character in list(worldpack.get("characters") or []): + payload = dict(character or {}) + role = str(payload.get("role") or "") + character_id = str(payload.get("character_id") or "") + if role and character_id: + role_to_character_ids.setdefault(role, []).append(character_id) + chapter_heatmap = [] + for chapter in chapters: + diagnostic = dict(chapter.get("diagnostic_summary_json") or {}) + if not diagnostic: + continue + issue_codes = [ + str(item.get("issue_code") or "") + for item in list(diagnostic.get("issues") or []) + if str(item.get("issue_code") or "") + ] + state_snapshot = dict(chapter.get("state_snapshot_json") or {}) + coverage_context = dict((state_snapshot.get("metadata") or {}).get("coverage_context") or {}) + first_beat = dict((coverage_context.get("scene_beats") or [{}])[0] or {}) + event = dict(first_beat.get("event") or {}) + scene_function = str(event.get("scene_function") or "") + matched_scene = dict(scene_by_function.get(scene_function) or {}) + chapter_task = dict(chapter.get("chapter_task_json") or {}) + chapter_task_id = str(chapter_task.get("chapter_task_id") or "") + arc_id = chapter_task_id.rsplit("::task_", 1)[0] if "::task_" in chapter_task_id else "" + volume_id = arc_id.rsplit("::arc_", 1)[0] if "::arc_" in arc_id else "" + related_character_ids = [ + character_id + for role in list((matched_scene.get("required_roles") or [])) + for character_id in role_to_character_ids.get(str(role), []) + ] + chapter_heatmap.append( + { + "chapter_index": int(chapter.get("chapter_index", 0) or 0), + "chapter_title": str(chapter.get("chapter_title") or ""), + "decision": str((diagnostic.get("decision") or {}).get("decision") or "rewrite"), + "severity": "critical" if str((diagnostic.get("decision") or {}).get("decision") or "") == "block" else ("watch" if str((diagnostic.get("decision") or {}).get("decision") or "") == "rewrite" else "stable"), + "overall_score": float((diagnostic.get("scores") or {}).get("overall_score", 0.0) or 0.0), + "issue_count": len(issue_codes), + "issue_codes": issue_codes, + "scene_function": scene_function, + "scene_id": str(matched_scene.get("scene_id") or ""), + "chapter_task_id": chapter_task_id, + "arc_id": arc_id, + "volume_id": volume_id, + "related_character_ids": related_character_ids, + "related_characters": related_character_ids, + } + ) + helper = AuthoringService(self.repository, registry=self.registry) + simulation_like = { + "chapter_evaluations": diagnostic_payloads, + "creative_cockpit": { + "chapter_heatmap": { + "chapters": chapter_heatmap, + "issue_priority_groups": helper._build_issue_priority_groups(chapter_heatmap), + } + }, + "longform_plan_snapshot": runtime_context.get("longform_structure") or {}, + } + return helper._build_content_quality_repair_workbench(worldpack, simulation_like) + + def _chapter_payload(self, chapter: Dict[str, Any]) -> Dict[str, Any]: + payload = dict(chapter or {}) + choices = list(payload.get("choices_json") or []) + return { + **payload, + "chapter_task": dict(payload.get("chapter_task_json") or {}), + "choices": choices, + "choice_impacts": build_choice_impacts( + choices, + chapter_index=int(payload.get("chapter_index") or 0), + ), + "latest_diagnostic_summary": self._normalized_chapter_diagnostics(payload.get("diagnostic_summary_json")), + } + + def _work_detail_payload(self, work: Dict[str, Any], *, include_chapters: bool = True) -> Dict[str, Any]: + normalized_work = self._ensure_work_branch_metadata(work) + chapters = self.repository.list_author_work_chapters(work_id=normalized_work["work_id"]) if include_chapters else [] + revisions = self.repository.list_author_work_revisions(work_id=normalized_work["work_id"], limit=20) + normalized_chapters = [self._chapter_payload(item) for item in chapters] + latest_guard = dict(((revisions[0].get("snapshot_json") or {}).get("quality_gate") or {})) if revisions else {} + repair_workbench = self._work_content_quality_repair_workbench(work=normalized_work, chapters=chapters) if include_chapters else {"available": False, "windows": {}, "default_campaign": {}, "campaigns": [], "next_actions": []} + default_campaign = dict(repair_workbench.get("default_campaign") or {}) + return { + **normalized_work, + "chapters": normalized_chapters, + "revisions": revisions, + "latest_revision": revisions[0] if revisions else None, + "active_chapter_index": normalized_chapters[-1]["chapter_index"] if normalized_chapters else None, + "diagnostics_summary": self._normalized_work_diagnostics(normalized_work.get("diagnostics_summary_json")), + "hard_constraint_status": "blocked" if latest_guard.get("failed_checks") or default_campaign.get("issue_code") else "clear", + "blocking_dimension": str(latest_guard.get("blocking_dimension") or latest_guard.get("primary_issue_group") or default_campaign.get("issue_code") or ""), + "window_breach_kind": str(latest_guard.get("window_breach_kind") or default_campaign.get("breach_kind") or ""), + "ready_for_validation": False if latest_guard.get("failed_checks") else not bool(default_campaign), + "content_quality_repair_workbench": repair_workbench, + "branch_family": self._branch_family(normalized_work), + } + + def get_work(self, work_id: str) -> Dict[str, Any]: + work = self.repository.get_author_work(work_id) + return self._work_detail_payload(work) + + def get_work_chapter(self, *, work_id: str, chapter_index: int) -> Dict[str, Any]: + work = self.repository.get_author_work(work_id) + chapter = self.repository.get_author_work_chapter(work_id=work_id, chapter_index=chapter_index) + return { + "work": self._work_detail_payload(work, include_chapters=False), + "chapter": self._chapter_payload(chapter), + } + + def _resolve_export_work(self, *, work_id: str, route: str) -> Dict[str, Any]: + requested_work = self._ensure_work_branch_metadata(self.repository.get_author_work(work_id)) + normalized_route = str(route or "active").strip() + family = self._branch_family(requested_work) + selected = None + if normalized_route in {"active", ""}: + selected = next((item for item in family if item.get("is_active_line")), None) + elif normalized_route in {"main", "mainline"}: + selected = next((item for item in family if item.get("branch_kind") == "mainline"), None) + else: + selected = next( + ( + item + for item in family + if str(item.get("work_id")) == normalized_route + or str(item.get("branch_name")) == normalized_route + or str(item.get("branch_origin_label")) == normalized_route + ), + None, + ) + return self._ensure_work_branch_metadata( + self.repository.get_author_work((selected or requested_work)["work_id"]) + ) + + def export_work_nosbook(self, *, work_id: str, route: str = "active") -> Dict[str, Any]: + export_work = self._resolve_export_work(work_id=work_id, route=route) + chapters = self.repository.list_author_work_chapters(work_id=export_work["work_id"]) + family = self._branch_family(export_work) + route_index = next( + (index for index, item in enumerate(family) if item.get("work_id") == export_work["work_id"]), + 0, + ) + route_name = self._route_display_name(export_work, route_index) + exported_chapters = [] + for chapter in chapters: + choices = list(chapter.get("choices_json") or []) + exported_chapters.append( + { + "chapter_index": int(chapter.get("chapter_index") or 0), + "chapter_title": chapter.get("chapter_title"), + "body": chapter.get("body") or "", + "summary": chapter.get("summary") or "", + "choices": choices, + "choice_impacts": build_choice_impacts( + choices, + chapter_index=int(chapter.get("chapter_index") or 0), + ), + } + ) + choice_history = [] + for index, branch in enumerate(family): + if not branch.get("branch_origin_label"): + continue + choice_history.append( + { + "route_name": self._route_display_name(branch, index), + "chapter_index": int(branch.get("fork_after_chapter_index") or 0), + "selected_choice": branch.get("branch_origin_label"), + "selected_at": branch.get("updated_at"), + "expected_effect": "从这一章后开启新的路线", + } + ) + title = str(export_work.get("title") or "NarrativeOS Work").strip() or "NarrativeOS Work" + safe_name = "".join(ch if ch.isascii() and ch.isalnum() else "-" for ch in title).strip("-") or "narrativeos-work" + return { + "schema_version": "nosbook/v1", + "content_type": "application/vnd.narrativeos.nosbook+json", + "filename": f"{safe_name}.nosbook", + "work": { + "title": title, + "world_version_id": export_work.get("world_version_id"), + "route_name": route_name, + "chapter_count": len(exported_chapters), + "target_chapter_count": int(export_work.get("target_chapter_count") or 0), + }, + "export_route": { + "route": route or "active", + "route_name": route_name, + "is_active_line": bool(export_work.get("is_active_line")), + }, + "chapters": exported_chapters, + "branch_map": self._branch_map_payload(work=export_work, export_work_id=export_work["work_id"]), + "choice_history": choice_history, + "quality_summary": self._product_quality_summary(work=export_work, chapters=chapters), + "cover": { + "mode": "default", + "source": "agent_studio_default_cover", + "metadata": {}, + }, + } + + def _generate_target_count(self, *, mode: str, existing_count: int, state: NarrativeState) -> int: + if mode == "first": + return 1 if existing_count == 0 else 0 + if mode == "next": + return 1 + if mode == "arc": + current_arc_target = int((state.metadata or {}).get("longform_progression", {}).get("arc_target_chapters", 0) or 0) + current_arc_index = int((state.metadata or {}).get("longform_progression", {}).get("arc_chapter_index", 0) or 0) + remaining = max(1, current_arc_target - current_arc_index) if current_arc_target else 3 + return min(remaining, 6) + return 1 + + def generate_chapters(self, *, work_id: str, mode: str) -> Dict[str, Any]: + normalized_mode = str(mode or "").strip() or "next" + if normalized_mode not in VALID_AUTHOR_WORK_GENERATION_MODES: + raise ValueError("invalid_author_work_generation_mode") + work = self.repository.get_author_work(work_id) + runtime_context = self._runtime_context(world_version_id=work["world_version_id"]) + min_target_words = self._quality_gate_min_target_words(runtime_context) + target_chapters = int( + work.get("target_chapter_count") + or (((runtime_context.get("longform_structure") or {}).get("series_plan") or {}).get("total_chapter_target") or 0) + ) + state = self._configured_state(work=work, runtime_context=runtime_context) + last_persisted_state = NarrativeState.from_dict(state.to_dict()) + runtime = runtime_context["runtime"] + chapters = self.repository.list_author_work_chapters(work_id=work_id) + generated_any = False + generate_count = self._generate_target_count(mode=normalized_mode, existing_count=len(chapters), state=state) + if generate_count <= 0: + return self.get_work(work_id) + for _ in range(generate_count): + candidate_provider = ( + self.provider_routing.build_candidate_provider( + runtime.event_atoms, + surface="author_work_generation", + account_id=work["account_id"], + session_id=f"author_work:{work_id}", + world_id=runtime.worldpack.world_id, + world_version_id=work["world_version_id"], + ) + if self.provider_routing + else StaticCandidateProvider(runtime.event_atoms) + ) + renderer = ( + self.provider_routing.build_renderer( + surface="author_work_generation", + account_id=work["account_id"], + session_id=f"author_work:{work_id}", + world_id=runtime.worldpack.world_id, + world_version_id=work["world_version_id"], + ) + if self.provider_routing + else TemplateRenderer() + ) + result = plan_next_turn( + state, + world=runtime.world_record.world, + candidate_provider=candidate_provider, + renderer=renderer, + debug=False, + ) + if result.get("status") != "ok": + break + if isinstance(result.get("reader_view"), dict): + sanitized_reader_view, reader_visible_language_debug = sanitize_reader_visible_payload(dict(result["reader_view"] or {})) + result["reader_view"] = sanitized_reader_view + if isinstance(result.get("rendered_scene"), dict): + rendered_debug = dict((result["rendered_scene"].get("debug") or {})) + rendered_debug["reader_visible_language_debug"] = reader_visible_language_debug + result["rendered_scene"] = { + **dict(result["rendered_scene"] or {}), + "debug": rendered_debug, + } + state = NarrativeState.from_dict(result["updated_state"]) + chapter_index = int((result.get("updated_state_summary") or {}).get("chapter_index") or state.chapter_index or (len(chapters) + 1)) + state_snapshot = state.to_dict() + coverage_metadata = dict(state_snapshot.get("metadata") or {}) + chapter_task = dict((result.get("chapter_plan") or {}).get("chapter_task") or {}) + coverage_metadata["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(chapter_task), + } + coverage_metadata["reader_visible_language_debug"] = dict( + ((result.get("rendered_scene") or {}).get("debug") or {}).get("reader_visible_language_debug") or {} + ) + state_snapshot["metadata"] = coverage_metadata + body = _trim_generated_author_work_body( + str(result["reader_view"].get("body") or ""), + min_units=1800, + max_units=2200, + ) + lint = lint_chapter_draft(body) + quality_bundle = evaluate_persisted_chapter( + chapter_id=f"{work_id}::{chapter_index}", + world_version_id=work["world_version_id"], + session_id=f"author_work:{work_id}", + body=body, + paragraphs=body.split("\n\n"), + dialogue_count=int(lint["dialogue_count"]), + action_count=int(lint["action_count"]), + detail_count=int(lint["detail_count"]), + character_fidelity_score=max( + [item["components"].get("character_fidelity", 0.0) for item in result.get("scored_candidates", [])], + default=0.75, + ), + state_after=state, + ending_ready=bool((result.get("chapter_plan") or {}).get("ending_ready")), + chapter_title=result["reader_view"].get("chapter_title"), + recap=result["reader_view"].get("recap"), + relationship_hints=list(result["reader_view"].get("relationship_hints") or []), + choices=list(result["reader_view"].get("choices") or []), + paywall_required=False, + coverage_context=coverage_metadata["coverage_context"], + target_words=chapter_task.get("target_words"), + min_target_words=min_target_words, + chapter_index=chapter_index, + target_chapters=target_chapters, + story_phase=str(state.story_phase or ""), + rolling_quality_window=list((last_persisted_state.metadata or {}).get("quality_contract_window", [])), + enforcement_scope="author_work_generation", + ) + try: + persist_guardrail_records( + self.repository, + quality_bundle=quality_bundle, + scenario_id="author_generate_chapter", + source_surface="author", + source_ref={ + "kind": "author_work_chapter", + "work_id": work_id, + "chapter_id": f"{work_id}::{chapter_index}", + "rendered_text": body, + }, + world_version_id=work["world_version_id"], + session_id=f"author_work:{work_id}", + chapter_id=f"{work_id}::{chapter_index}", + coverage_context=coverage_metadata["coverage_context"], + state_after=state, + worldpack_payload=runtime.worldpack.to_dict() if hasattr(runtime.worldpack, "to_dict") else None, + ) + except Exception: + pass + if not quality_bundle["quality_gate"]["ok"]: + if generated_any: + chapters = sorted(chapters, key=lambda item: int(item["chapter_index"])) + work = self.repository.save_author_work( + { + **work, + "chapter_count": len(chapters), + "narrative_state_json": last_persisted_state.to_dict(), + "status": "draft", + } + ) + work = self._revision( + work=work, + chapters=chapters, + revision_type="generation", + summary=f"生成章节 · mode={normalized_mode} · partial_before_quality_guard_failure", + ) + self._record_quality_guard_failure( + work=work, + chapters=chapters, + chapter_index=chapter_index, + surface="author_work_generation", + quality_gate=quality_bundle["quality_gate"], + ) + raise ChapterQualityGuardError(quality_bundle["quality_gate"]) + quality_contract_window = list(quality_bundle["quality_gate"].get("quality_contract_window") or []) + state.metadata = { + **dict(state.metadata or {}), + "quality_contract_window": quality_contract_window, + } + coverage_metadata["quality_contract_window"] = quality_contract_window + state_snapshot["metadata"] = coverage_metadata + saved = self.repository.save_author_work_chapter( + { + "work_id": work_id, + "chapter_index": chapter_index, + "chapter_title": result["reader_view"].get("chapter_title") or f"第 {chapter_index} 章", + "body": body, + "status": "generated", + "source_type": "generated" if not chapters else "regenerated" if any(item["chapter_index"] == chapter_index for item in chapters) else "generated", + "summary": body[:120], + "chapter_task_json": chapter_task, + "choices_json": list(result["reader_view"].get("choices") or []), + "state_snapshot_json": state_snapshot, + } + ) + chapters = [item for item in chapters if item["chapter_index"] != chapter_index] + [saved] + generated_any = True + last_persisted_state = NarrativeState.from_dict(state.to_dict()) + chapters = sorted(chapters, key=lambda item: int(item["chapter_index"])) + work = self.repository.save_author_work( + { + **work, + "chapter_count": len(chapters), + "narrative_state_json": state.to_dict(), + "status": "draft", + } + ) + work = self._revision(work=work, chapters=chapters, revision_type="generation", summary=f"生成章节 · mode={normalized_mode}") + return self.get_work(work["work_id"]) + + def edit_chapter( + self, + *, + work_id: str, + chapter_index: int, + title: Optional[str] = None, + body: Optional[str] = None, + summary: Optional[str] = None, + ) -> Dict[str, Any]: + work = self.repository.get_author_work(work_id) + chapter = self.repository.get_author_work_chapter(work_id=work_id, chapter_index=chapter_index) + runtime_context = self._runtime_context(world_version_id=work["world_version_id"]) + min_target_words = self._quality_gate_min_target_words(runtime_context) + target_chapters = int( + work.get("target_chapter_count") + or (((runtime_context.get("longform_structure") or {}).get("series_plan") or {}).get("total_chapter_target") or 0) + ) + next_title = title or chapter["chapter_title"] + next_body = body if body is not None else chapter["body"] + next_summary = summary if summary is not None else chapter.get("summary") + state_after_payload = dict(chapter.get("state_snapshot_json") or {}) + state_after = ( + NarrativeState.from_dict(state_after_payload) + if state_after_payload + else NarrativeState.from_dict( + { + "state_id": f"{work_id}::edit::{chapter_index}", + "world_id": work["world_version_id"], + "turn_index": int(chapter_index), + "story_phase": "setup", + "chapter_index": int(chapter_index), + "min_end_turn": max(8, int(chapter_index)), + "fate_pressure": 0.0, + "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", + } + ) + ) + coverage_context = dict((state_after_payload.get("metadata") or {}).get("coverage_context") or {}) + coverage_context["chapter_task"] = dict(chapter.get("chapter_task_json") or {}) + lint = lint_chapter_draft(next_body) + quality_bundle = evaluate_persisted_chapter( + chapter_id=f"{work_id}::{chapter_index}", + world_version_id=work["world_version_id"], + session_id=f"author_work:{work_id}", + body=next_body, + paragraphs=next_body.split("\n\n"), + dialogue_count=int(lint["dialogue_count"]), + action_count=int(lint["action_count"]), + detail_count=int(lint["detail_count"]), + character_fidelity_score=float( + (((chapter.get("diagnostic_summary_json") or {}).get("scores") or {}).get("character_fidelity", 1.0) or 1.0) + ), + state_after=state_after, + ending_ready=bool((chapter.get("chapter_task_json") or {}).get("allow_terminal")), + chapter_title=next_title, + choices=list(chapter.get("choices_json") or []), + paywall_required=False, + coverage_context=coverage_context, + target_words=(chapter.get("chapter_task_json") or {}).get("target_words"), + min_target_words=min_target_words, + chapter_index=chapter_index, + target_chapters=target_chapters, + story_phase=str(state_after.story_phase or ""), + rolling_quality_window=list((state_after.metadata or {}).get("quality_contract_window", [])), + enforcement_scope="author_work_manual_edit", + ) + try: + persist_guardrail_records( + self.repository, + quality_bundle=quality_bundle, + scenario_id="author_manual_edit", + source_surface="author", + source_ref={ + "kind": "author_work_chapter", + "work_id": work_id, + "chapter_id": f"{work_id}::{chapter_index}", + "rendered_text": next_body, + }, + world_version_id=work["world_version_id"], + session_id=f"author_work:{work_id}", + chapter_id=f"{work_id}::{chapter_index}", + coverage_context=coverage_context, + state_after=state_after, + worldpack_payload=(runtime_context.get("runtime").worldpack.to_dict() if hasattr(runtime_context.get("runtime").worldpack, "to_dict") else None), + ) + except Exception: + pass + if not quality_bundle["quality_gate"]["ok"]: + chapters = self.repository.list_author_work_chapters(work_id=work_id) + self._record_quality_guard_failure( + work=work, + chapters=chapters, + chapter_index=chapter_index, + surface="author_work_manual_edit", + quality_gate=quality_bundle["quality_gate"], + ) + raise ChapterQualityGuardError(quality_bundle["quality_gate"]) + state_after.metadata = { + **dict(state_after.metadata or {}), + "quality_contract_window": list(quality_bundle["quality_gate"].get("quality_contract_window") or []), + } + state_after_payload["metadata"] = dict(state_after.metadata or {}) + updated_chapter = self.repository.save_author_work_chapter( + { + **chapter, + "chapter_title": next_title, + "body": next_body, + "summary": next_summary, + "status": "edited", + "source_type": "manual_edit", + "state_snapshot_json": state_after.to_dict(), + } + ) + chapters = self.repository.list_author_work_chapters(work_id=work_id) + work = self.repository.save_author_work( + { + **work, + "status": "draft", + } + ) + work = self._revision(work=work, chapters=chapters, revision_type="chapter_edit", summary=f"编辑第 {chapter_index} 章") + return { + "work": self.get_work(work_id), + "chapter": updated_chapter, + } + + def run_diagnostics(self, *, work_id: str) -> Dict[str, Any]: + work = self.repository.get_author_work(work_id) + chapters = self.repository.list_author_work_chapters(work_id=work_id) + reports = self._chapter_reports(chapters, world_version_id=work["world_version_id"], work_id=work_id) + aggregated = aggregate_reports(EvaluationReport.from_dict(item) for item in reports) + revisions = self.repository.list_author_work_revisions(work_id=work_id, limit=1) + chapter_map = {item["chapter_index"]: item for item in chapters} + for report in reports: + chapter_index = int(str(report["chapter_id"]).rsplit("::", 1)[-1] or 0) + chapter = chapter_map.get(chapter_index) + if chapter: + self.repository.save_author_work_chapter( + { + **chapter, + "diagnostic_summary_json": report, + } + ) + latest_decision = self._aggregate_work_decision(aggregated) + if revisions and str(revisions[0].get("revision_type") or "") == "quality_guard_blocked" and latest_decision == "pass": + latest_decision = "rewrite" + next_status = "review_ready" if latest_decision == "pass" else "needs_changes" + work = self.repository.save_author_work( + { + **work, + "diagnostics_summary_json": { + "chapter_count": len(chapters), + "evaluation_summary": aggregated, + "latest_decision": latest_decision, + }, + "status": next_status, + } + ) + chapters = self.repository.list_author_work_chapters(work_id=work_id) + work = self._revision(work=work, chapters=chapters, revision_type="diagnostics", summary="运行作品稿诊断") + return { + "work": self.get_work(work_id), + "reports": reports, + "evaluation_summary": aggregated, + } + + def submit_work(self, *, work_id: str) -> Dict[str, Any]: + work = self.repository.get_author_work(work_id) + diagnostics = dict(work.get("diagnostics_summary_json") or {}) + evaluation = dict(diagnostics.get("evaluation_summary") or {}) + if not diagnostics: + raise ValueError("author_work_requires_diagnostics") + if str(diagnostics.get("latest_decision") or "") != "pass": + raise ValueError("author_work_blocked_by_diagnostics") + if float(evaluation.get("block_rate", 0.0) or 0.0) > 0.0: + raise ValueError("author_work_blocked_by_diagnostics") + chapters = self.repository.list_author_work_chapters(work_id=work_id) + work = self.repository.save_author_work( + { + **work, + "status": "submitted", + } + ) + work = self._revision(work=work, chapters=chapters, revision_type="submit", summary="作品稿送审") + self.repository.save_review_record( + { + "asset_type": "author_work", + "asset_id": work_id, + "status": "submitted", + "reviewer_id": None, + "risk_rating": "PG-13", + "notes": f"author_work::{work_id}::{work['world_version_id']}", + } + ) + return self.get_work(work_id) diff --git a/src/narrativeos/services/authoring.py b/src/narrativeos/services/authoring.py index 1e86ad2..b03feb7 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,9 +5009,19 @@ def _workflow_stage_and_action( validation_summary: Dict[str, Any], simulation_summary: Dict[str, Any], simulation_freshness: Dict[str, Any], + 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 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 version.status == "submitted": return "submitted", "wait_for_review" if not validation_summary.get("available"): @@ -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: @@ -953,6 +5406,7 @@ def workflow_summary( validation_summary=validation_summary, simulation_summary=simulation_summary, simulation_freshness=simulation_freshness, + longform_readiness=longform_capability["longform_readiness"], ) return { "account_id": resolved_account_id, @@ -960,6 +5414,24 @@ def workflow_summary( "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 +5458,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 +5487,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 +5520,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 +5549,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 +5589,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 +5629,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 +5683,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 +5695,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 +5783,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 +5841,114 @@ 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()) 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 {}) + 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 +5972,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 +6013,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 +6057,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 +6141,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 +6203,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 +6554,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 +6624,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 +6641,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 +6658,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 +6675,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 +7599,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/billing.py b/src/narrativeos/services/billing.py index fdfc7af..43de2a8 100644 --- a/src/narrativeos/services/billing.py +++ b/src/narrativeos/services/billing.py @@ -26,12 +26,40 @@ def _utcnow(self) -> datetime: def _billing_provider(self) -> str: return str(os.getenv("NARRATIVEOS_BILLING_PROVIDER", "web_stub")) + def _stripe_webhook_secret(self) -> Optional[str]: + value = str(os.getenv("NARRATIVEOS_STRIPE_WEBHOOK_SECRET", "")).strip() + return value or None + + def _stripe_payload(self, value: Any) -> Dict[str, Any]: + if hasattr(value, "to_dict"): + return dict(value.to_dict()) + return dict(value) + def _checkout_session_ttl_minutes(self) -> int: try: return max(5, int(os.getenv("NARRATIVEOS_CHECKOUT_SESSION_TTL_MINUTES", "60"))) except ValueError: return 60 + def _paid_pilot_invite_only_enabled(self) -> bool: + return str(os.getenv("NARRATIVEOS_PAID_PILOT_INVITE_ONLY", "")).strip().lower() in { + "1", + "true", + "yes", + "on", + } + + def _paid_pilot_invited_accounts(self) -> set[str]: + raw = str(os.getenv("NARRATIVEOS_PAID_PILOT_INVITED_ACCOUNTS", "")).strip() + return {item.strip() for item in raw.split(",") if item.strip()} + + def _require_paid_pilot_checkout_invite(self, *, account_id: str) -> None: + if not self._paid_pilot_invite_only_enabled(): + return + invited = self._paid_pilot_invited_accounts() + if str(account_id or "").strip() not in invited: + raise ValueError("checkout_invite_required") + def _retry_max_attempts(self) -> int: try: return max(1, int(os.getenv("NARRATIVEOS_BILLING_RETRY_MAX_ATTEMPTS", "3"))) @@ -47,8 +75,13 @@ def _retry_backoff_minutes(self) -> int: def _parse_expires_at(self, value: Optional[str]) -> Optional[datetime]: if not value: return None + if isinstance(value, datetime): + parsed = value + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) try: - normalized = value.replace("Z", "+00:00") + normalized = str(value).replace("Z", "+00:00") parsed = datetime.fromisoformat(normalized) if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) @@ -56,6 +89,43 @@ def _parse_expires_at(self, value: Optional[str]) -> Optional[datetime]: except ValueError: return None + def _serialize_datetime(self, value: Any) -> Optional[str]: + if value in {None, ""}: + return None + parsed = self._parse_expires_at(value) + if parsed is None: + return str(value) + return parsed.isoformat() + + def _timestamp_to_iso(self, value: Any) -> Optional[str]: + if value in {None, ""}: + return None + try: + return datetime.fromtimestamp(int(value), tz=timezone.utc).isoformat() + except (TypeError, ValueError, OSError): + return None + + def _stripe_checkout_status(self, payload: Dict[str, Any]) -> str: + status = str(payload.get("status") or "") + if status == "complete": + return "completed" + if status == "expired": + return "expired" + return "created" + + def _stripe_subscription_status(self, value: Optional[str], *, fallback: str = "active") -> str: + status = str(value or "").strip() + return { + "trialing": "trialing", + "active": "active", + "past_due": "past_due", + "unpaid": "past_due", + "incomplete": "past_due", + "paused": "paused", + "canceled": "canceled", + "incomplete_expired": "canceled", + }.get(status, fallback) + def resolve_account_id( self, *, @@ -74,6 +144,7 @@ def _normalize_entitlement(self, entitlement: Dict[str, Any], *, world_id: Optio balance = payload.get("balance") payload["balance"] = float(balance) if balance is not None else None expires_at = self._parse_expires_at(payload.get("expires_at")) + payload["expires_at"] = self._serialize_datetime(payload.get("expires_at")) status = payload.get("status") or "active" reason = "active_entitlement" if status == "revoked": @@ -127,8 +198,8 @@ def _subscription_snapshot(self, subscription: Dict[str, Any]) -> Dict[str, Any] "price_usd_monthly": tier.get("price_usd_monthly"), "status": subscription["status"], "provider": subscription["provider"], - "period_start": subscription.get("period_start"), - "period_end": subscription.get("period_end"), + "period_start": self._serialize_datetime(subscription.get("period_start")), + "period_end": self._serialize_datetime(subscription.get("period_end")), "cancel_at_period_end": bool(subscription.get("cancel_at_period_end")), "reader_access": bool(tier.get("reader_access")), "author_access": tier.get("author_access", "none"), @@ -243,7 +314,12 @@ def _find_subscription_for_event(self, *, account_id: Optional[str], subscriptio try: return self.repository.get_subscription(subscription_id) except KeyError: - return None + subscriptions = self.repository.list_subscriptions(account_id=account_id) + provider_match = next((item for item in subscriptions if str(item.get("provider_ref") or "") == str(subscription_id)), None) + if provider_match is not None: + return provider_match + subscriptions = self.repository.list_subscriptions(account_id=None) + return next((item for item in subscriptions if str(item.get("provider_ref") or "") == str(subscription_id)), None) if not account_id: return None subscriptions = self.monetization.list_subscriptions(account_id=account_id) @@ -258,6 +334,345 @@ def _checkout_session_summary(self, account_id: str) -> Dict[str, Any]: "recent_checkout_sessions": sessions, } + def _stripe_customer_id_for_account(self, account_id: str) -> Optional[str]: + lifecycle = self.repository.list_billing_lifecycle_events(account_id=account_id, limit=50) + for item in lifecycle: + payload = dict(item.get("payload_json") or {}) + customer_id = payload.get("customer_id") + if customer_id: + return str(customer_id) + return None + + def _account_security_state(self, *, account_id: str) -> Dict[str, Any]: + profile = self.repository.get_auth_identity_profile(account_id, default=None) + if profile is None and account_id: + # Quantum compat registration stores the login identity on actor_id while + # keeping the frontend-facing account_id as the email/account surface. + identity = self.repository.get_auth_identity_by_account_id(account_id, default=None) + actor_id = str((identity or {}).get("actor_id") or "").strip() + if actor_id and actor_id != account_id: + profile = self.repository.get_auth_identity_profile(actor_id, default=None) + if profile is None: + return { + "email_address": account_id if "@" in str(account_id or "") else None, + "email_verified": False if "@" in str(account_id or "") else True, + "verification_required": bool("@" in str(account_id or "")), + "verification_sent_at": None, + "verified_at": None, + "password_reset_sent_at": None, + } + return { + "email_address": profile.get("email_address"), + "email_verified": bool(profile.get("email_verified")), + "verification_required": bool(profile.get("verification_required")), + "verification_sent_at": profile.get("verification_sent_at"), + "verified_at": profile.get("verified_at"), + "password_reset_sent_at": profile.get("password_reset_sent_at"), + } + + def _require_verified_email_for_billing(self, *, account_id: str) -> None: + security = self._account_security_state(account_id=account_id) + if security.get("verification_required") and not security.get("email_verified"): + raise ValueError("email_verification_required_for_billing") + + def _upsert_checkout_session_record( + self, + *, + checkout_session_id: str, + account_id: str, + tier_id: str, + provider: str, + status: str, + checkout_kind: str = "subscription", + package_id: Optional[str] = None, + provider_ref: Optional[str] = None, + checkout_url: Optional[str] = None, + subscription_id: Optional[str] = None, + expires_at: Optional[str] = None, + idempotency_key: Optional[str] = None, + fulfilled_at: Optional[str] = None, + ) -> Dict[str, Any]: + existing = None + try: + existing = self.repository.get_billing_checkout_session(checkout_session_id) + except KeyError: + existing = None + return self.repository.save_billing_checkout_session( + { + "checkout_session_id": checkout_session_id, + "account_id": account_id, + "checkout_kind": checkout_kind if checkout_kind is not None else (existing or {}).get("checkout_kind") or "subscription", + "tier_id": tier_id, + "package_id": package_id if package_id is not None else (existing or {}).get("package_id"), + "provider": provider, + "provider_ref": provider_ref or (existing or {}).get("provider_ref") or checkout_session_id, + "subscription_id": subscription_id if subscription_id is not None else (existing or {}).get("subscription_id"), + "status": status, + "checkout_url": checkout_url if checkout_url is not None else (existing or {}).get("checkout_url"), + "idempotency_key": idempotency_key or (existing or {}).get("idempotency_key") or f"{provider}:{account_id}:{tier_id}:{checkout_session_id}", + "expires_at": expires_at if expires_at is not None else (existing or {}).get("expires_at"), + "fulfilled_at": fulfilled_at if fulfilled_at is not None else (existing or {}).get("fulfilled_at"), + } + ) + + def _tier_rank(self, tier_id: Optional[str]) -> int: + tiers = [str(item.get("tier_id")) for item in self.monetization.tiers()] + try: + return tiers.index(str(tier_id)) + except ValueError: + return -1 + + def _provider_subscription_status_rank(self, status: Optional[str]) -> int: + return { + "active": 0, + "trialing": 1, + "past_due": 2, + "paused": 3, + "canceled": 4, + "expired": 5, + }.get(str(status or ""), 99) + + def _provider_subscription_snapshot(self, subscription: Dict[str, Any]) -> Dict[str, Any]: + tier = self.monetization.get_tier(subscription["tier_id"]) + return { + "provider_subscription_id": subscription["provider_subscription_id"], + "account_id": subscription["account_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"), + "tier_id": subscription["tier_id"], + "display_name": tier.get("display_name", subscription["tier_id"]), + "status": subscription["status"], + "environment": subscription.get("environment"), + "verification_status": subscription.get("verification_status"), + "last_verified_at": self._serialize_datetime(subscription.get("last_verified_at")), + "period_start": self._serialize_datetime(subscription.get("period_start")), + "period_end": self._serialize_datetime(subscription.get("period_end")), + "cancel_at_period_end": bool(subscription.get("cancel_at_period_end")), + "payload_json": dict(subscription.get("payload_json") or {}), + "updated_at": self._serialize_datetime(subscription.get("updated_at")), + } + + def _list_provider_subscriptions(self, *, account_id: str) -> List[Dict[str, Any]]: + return [ + self._provider_subscription_snapshot(item) + for item in self.repository.list_provider_subscriptions(account_id=account_id) + ] + + def _effective_provider_subscription(self, *, account_id: str) -> Optional[Dict[str, Any]]: + provider_subscriptions = self.repository.list_provider_subscriptions(account_id=account_id) + if not provider_subscriptions: + return None + provider_subscriptions.sort( + key=lambda item: ( + self._provider_subscription_status_rank(item.get("status")), + -self._tier_rank(item.get("tier_id")), + str(item.get("period_end") or ""), + str(item.get("updated_at") or ""), + ), + reverse=False, + ) + best = sorted( + provider_subscriptions, + key=lambda item: ( + self._provider_subscription_status_rank(item.get("status")), + -self._tier_rank(item.get("tier_id")), + -(self._parse_expires_at(item.get("period_end")) or datetime.fromtimestamp(0, tz=timezone.utc)).timestamp(), + -(self._parse_expires_at(item.get("updated_at")) or datetime.fromtimestamp(0, tz=timezone.utc)).timestamp(), + ), + )[0] + return best + + def _provider_source_summary(self, *, account_id: str) -> Dict[str, Any]: + items = self.repository.list_provider_subscriptions(account_id=account_id) + effective = self._effective_provider_subscription(account_id=account_id) + by_provider: Dict[str, int] = {} + environments: Dict[str, int] = {} + for item in items: + provider = str(item.get("provider") or "unknown") + environment = str(item.get("environment") or "unknown") + by_provider[provider] = by_provider.get(provider, 0) + 1 + environments[environment] = environments.get(environment, 0) + 1 + return { + "provider_subscription_count": len(items), + "by_provider": by_provider, + "by_environment": environments, + "effective_provider": effective.get("provider") if effective else None, + "effective_provider_ref": effective.get("provider_ref") if effective else None, + "effective_verification_status": effective.get("verification_status") if effective else None, + } + + def _refund_dispute_summary(self, *, account_id: str) -> Dict[str, Any]: + events = self.repository.list_billing_lifecycle_events(account_id=account_id, limit=50) + refund_events = [item for item in events if item.get("event_type") in {"payment_refunded"}] + dispute_events = [item for item in events if item.get("event_type") in {"charge_disputed", "charge_dispute_closed"}] + active_disputes = [item for item in dispute_events if item.get("event_type") == "charge_disputed"] + latest = (refund_events + dispute_events)[:10] + return { + "refund_count": len(refund_events), + "dispute_count": len(active_disputes), + "latest_events": latest, + } + + def _sync_effective_subscription_from_provider_records(self, *, account_id: str) -> Optional[Dict[str, Any]]: + effective = self._effective_provider_subscription(account_id=account_id) + if effective is None: + return None + existing = self._find_subscription_for_event(account_id=account_id, subscription_id=None) + if existing is None or existing.get("provider") in {"stripe", "app_store", "google_play"}: + payload = { + "account_id": account_id, + "tier_id": effective["tier_id"], + "provider": effective["provider"], + "provider_ref": effective.get("provider_ref"), + "status": effective["status"], + "period_start": effective.get("period_start"), + "period_end": effective.get("period_end"), + "cancel_at_period_end": effective.get("cancel_at_period_end"), + } + if existing is not None: + payload["subscription_id"] = existing["subscription_id"] + current = self.repository.save_subscription(payload) + if current.get("status") in {"trialing", "active"}: + self.monetization.refill_subscription_wallets(current["subscription_id"]) + return current + return existing + + def _upsert_provider_subscription_record( + self, + *, + account_id: str, + provider: str, + tier_id: str, + provider_ref: Optional[str], + status: str, + environment: str = "test", + verification_status: str = "verified", + last_verified_at: Optional[str] = None, + period_start: Optional[str] = None, + period_end: Optional[str] = None, + cancel_at_period_end: bool = False, + provider_customer_id: Optional[str] = None, + provider_checkout_session_id: Optional[str] = None, + provider_order_id: Optional[str] = None, + latest_event_id: Optional[str] = None, + payload_json: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + existing = self.repository.get_provider_subscription_by_ref( + provider=provider, + provider_ref=provider_ref, + provider_checkout_session_id=provider_checkout_session_id, + provider_order_id=provider_order_id, + default=None, + ) + payload: Dict[str, Any] = { + "account_id": account_id, + "tier_id": tier_id, + "provider": provider, + "provider_ref": provider_ref, + "provider_customer_id": provider_customer_id, + "provider_checkout_session_id": provider_checkout_session_id, + "provider_order_id": provider_order_id, + "environment": environment, + "verification_status": verification_status, + "last_verified_at": last_verified_at or self._utcnow().isoformat(), + "status": status, + "period_start": period_start, + "period_end": period_end, + "cancel_at_period_end": cancel_at_period_end, + "latest_event_id": latest_event_id, + "payload_json": dict(payload_json or {}), + } + if existing is not None: + payload["provider_subscription_id"] = existing["provider_subscription_id"] + saved = self.repository.save_provider_subscription(payload) + self._sync_effective_subscription_from_provider_records(account_id=account_id) + return saved + + def _reconcile_external_provider_subscription( + self, + *, + account_id: str, + provider_payload: Dict[str, Any], + latest_event_id: Optional[str] = None, + ) -> Dict[str, Any]: + provider_record = self._upsert_provider_subscription_record( + account_id=account_id, + provider=str(provider_payload["provider"]), + tier_id=str(provider_payload["tier_id"]), + provider_ref=provider_payload.get("provider_ref"), + provider_customer_id=provider_payload.get("provider_customer_id"), + provider_checkout_session_id=provider_payload.get("provider_checkout_session_id"), + provider_order_id=provider_payload.get("provider_order_id"), + status=str(provider_payload.get("status") or "active"), + environment=str(provider_payload.get("environment") or "test"), + verification_status=str(provider_payload.get("verification_status") or "verified"), + last_verified_at=self._utcnow().isoformat(), + period_start=provider_payload.get("period_start"), + period_end=provider_payload.get("period_end"), + cancel_at_period_end=bool(provider_payload.get("cancel_at_period_end")), + latest_event_id=latest_event_id, + payload_json=dict(provider_payload.get("payload_json") or {}), + ) + effective = self._sync_effective_subscription_from_provider_records(account_id=account_id) + return { + "provider_subscription": self._provider_subscription_snapshot(provider_record), + "effective_subscription": self._subscription_snapshot(effective) if effective else None, + } + + def _reconcile_stripe_subscription_record( + self, + *, + account_id: str, + tier_id: str, + stripe_subscription: Dict[str, Any], + local_subscription_id: Optional[str] = None, + ) -> Dict[str, Any]: + provider_ref = str(stripe_subscription.get("id") or "").strip() + local_subscription = self._find_subscription_for_event( + account_id=account_id, + subscription_id=local_subscription_id or provider_ref or None, + ) + status = self._stripe_subscription_status( + stripe_subscription.get("status"), + fallback=(local_subscription or {}).get("status", "active"), + ) + period_start = self._timestamp_to_iso(stripe_subscription.get("current_period_start")) or (local_subscription or {}).get("period_start") + period_end = self._timestamp_to_iso(stripe_subscription.get("current_period_end")) or (local_subscription or {}).get("period_end") + cancel_at_period_end = bool(stripe_subscription.get("cancel_at_period_end")) + self._upsert_provider_subscription_record( + account_id=account_id, + provider="stripe", + tier_id=tier_id or (local_subscription or {}).get("tier_id") or "play_pass", + provider_ref=provider_ref or None, + provider_customer_id=str(stripe_subscription.get("customer") or "").strip() or None, + provider_checkout_session_id=None, + status=status, + environment="live" if str(os.getenv("NARRATIVEOS_STRIPE_SECRET_KEY", "")).startswith("sk_live_") else "test", + verification_status="verified", + period_start=period_start, + period_end=period_end, + cancel_at_period_end=cancel_at_period_end, + payload_json=stripe_subscription, + ) + effective = self._sync_effective_subscription_from_provider_records(account_id=account_id) + if effective is not None: + return effective + if local_subscription is not None: + return local_subscription + return self.monetization.create_subscription( + account_id=account_id, + tier_id=tier_id, + provider="stripe", + provider_ref=provider_ref or None, + status=status, + period_start=period_start, + period_end=period_end, + cancel_at_period_end=cancel_at_period_end, + ) + def _lifecycle_history_summary(self, account_id: str) -> Dict[str, Any]: events = self.repository.list_billing_lifecycle_events(account_id=account_id, limit=20) retries = self.repository.list_billing_retry_attempts(account_id=account_id, limit=20) @@ -494,6 +909,102 @@ def _normalize_audit_meter(self, meter: Dict[str, Any], *, account_id: str) -> D }, } + def _normalize_billing_lifecycle_event(self, event: Dict[str, Any], *, account_id: str) -> Dict[str, Any]: + payload = dict(event.get("payload_json") or {}) + processing_result = dict(event.get("processing_result") or {}) + event_type = str(event.get("event_type") or "billing_lifecycle_event") + object_type = "account" + object_id = account_id + subscription_id = event.get("subscription_id") or processing_result.get("subscription_id") + checkout_session_id = event.get("checkout_session_id") or processing_result.get("checkout_session_id") + if subscription_id: + object_type = "subscription" + object_id = subscription_id + elif checkout_session_id: + object_type = "checkout_session" + object_id = checkout_session_id + return { + "trail_id": "billing_%s" % event.get("event_id"), + "source_type": "billing_lifecycle_event", + "category": "checkout" if event_type.startswith("checkout_") else "subscription", + "surface": "reader" if event_type.startswith("checkout_") else "ops", + "action": event_type, + "occurred_at": event.get("occurred_at"), + "actor_id": payload.get("requested_by") or event.get("account_id") or account_id, + "target_account_id": account_id, + "object_type": object_type, + "object_id": object_id, + "status": event.get("status"), + "reason": processing_result.get("subscription_status") or payload.get("reason"), + "wallet_type": None, + "tier_id": processing_result.get("tier_id") or payload.get("tier_id"), + "balance": None, + "usage_units": None, + "session_id": payload.get("session_id"), + "reader_id": event.get("account_id") or account_id, + "world_id": payload.get("world_id"), + "world_version_id": payload.get("world_version_id"), + "headline": event_type.replace("_", " "), + "details": { + "provider": event.get("provider"), + "provider_event_id": event.get("provider_event_id"), + "checkout_session_id": checkout_session_id, + "subscription_id": subscription_id, + "payload": payload, + "processing_result": processing_result, + }, + } + + def _normalize_audit_log(self, entry: Dict[str, Any], *, account_id: str) -> Dict[str, Any]: + customer_payload = dict(entry.get("customer_visible_payload_json") or {}) + internal_payload = dict(entry.get("internal_payload_json") or {}) + action = str(entry.get("action_type") or "audit_log") + surface = str(entry.get("source_surface") or "ops") + status = str( + customer_payload.get("status") + or internal_payload.get("status") + or internal_payload.get("next_status") + or "recorded" + ) + reason = ( + customer_payload.get("summary") + or customer_payload.get("reason") + or internal_payload.get("note") + or internal_payload.get("resolution_notes") + ) + category = "ops" + if action.startswith("governance_"): + category = "governance" + elif action.startswith("ops_alert_"): + category = "alert" + return { + "trail_id": "audit_%s" % entry.get("audit_log_id"), + "source_type": "audit_log", + "category": category, + "surface": surface, + "action": action, + "occurred_at": entry.get("created_at"), + "actor_id": entry.get("actor_id"), + "target_account_id": account_id, + "object_type": entry.get("object_type"), + "object_id": entry.get("object_id"), + "status": status, + "reason": reason, + "wallet_type": None, + "tier_id": None, + "balance": None, + "usage_units": None, + "session_id": internal_payload.get("session_id"), + "reader_id": account_id, + "world_id": internal_payload.get("world_id"), + "world_version_id": internal_payload.get("world_version_id"), + "headline": action.replace("_", " "), + "details": { + "customer_visible_payload": customer_payload, + "internal_payload": internal_payload, + }, + } + def full_audit_trail(self, *, account_id: str, limit: int = 50) -> Dict[str, Any]: authored_world_version_ids = self._authored_world_version_ids(account_id, limit=max(limit, 10)) analytics_events = self.repository.list_analytics_events(reader_id=account_id, limit=max(limit * 4, 20)) @@ -507,9 +1018,13 @@ def full_audit_trail(self, *, account_id: str, limit: int = 50) -> Dict[str, Any deduped.setdefault(str(item.get("event_id")), item) analytics_events = list(deduped.values()) meters = self.repository.list_usage_meters(account_id=account_id)[: max(limit * 2, 20)] + lifecycle_events = self.repository.list_billing_lifecycle_events(account_id=account_id, limit=max(limit * 4, 20)) + audit_logs = self.repository.list_audit_logs(account_id=account_id, limit=max(limit * 4, 20)) trail = [ *[self._normalize_audit_event(item, account_id=account_id) for item in analytics_events], *[self._normalize_audit_meter(item, account_id=account_id) for item in meters], + *[self._normalize_billing_lifecycle_event(item, account_id=account_id) for item in lifecycle_events], + *[self._normalize_audit_log(item, account_id=account_id) for item in audit_logs], ] trail.sort(key=lambda item: (str(item.get("occurred_at") or ""), str(item.get("trail_id") or "")), reverse=True) has_more = len(trail) > limit @@ -549,6 +1064,8 @@ def full_audit_trail(self, *, account_id: str, limit: int = 50) -> Dict[str, Any "sources": { "analytics_events": len(analytics_events), "usage_meters": len(meters), + "billing_lifecycle_events": len(lifecycle_events), + "audit_logs": len(audit_logs), "authored_world_versions": len(authored_world_version_ids), }, }, @@ -644,6 +1161,9 @@ def list_entitlements_for_account(self, account_id: str, *, world_id: Optional[s **self._checkout_session_summary(account_id), "lifecycle_history_summary": self._lifecycle_history_summary(account_id), "entitlements": raw_entitlements, + "effective_tier": (self._effective_provider_subscription(account_id=account_id) or {}).get("tier_id") or (subscription or {}).get("tier_id"), + "provider_subscriptions": self._list_provider_subscriptions(account_id=account_id), + "provider_source_summary": self._provider_source_summary(account_id=account_id), **self._config_snapshot(), } @@ -735,8 +1255,13 @@ def list_subscriptions(self, *, account_id: Optional[str] = None, status: Option } def subscription_status(self, *, account_id: str) -> Dict[str, Any]: + self._sync_effective_subscription_from_provider_records(account_id=account_id) subscriptions = self.monetization.list_subscriptions(account_id=account_id) active = next((item for item in subscriptions if item["status"] in {"trialing", "active"}), None) or (subscriptions[0] if subscriptions else None) + stripe_customer_id = self._stripe_customer_id_for_account(account_id) + provider_subscriptions = self._list_provider_subscriptions(account_id=account_id) + effective_provider = self._effective_provider_subscription(account_id=account_id) + security_state = self._account_security_state(account_id=account_id) return { "account_id": account_id, "subscription": self._subscription_snapshot(active) if active else None, @@ -746,33 +1271,66 @@ def subscription_status(self, *, account_id: str) -> Dict[str, Any]: "retryable": bool(active and active.get("status") == "past_due"), "renewable": bool(active and active.get("status") in {"past_due", "canceled", "expired"}) if active else False, "recommended_action": self._recommended_subscription_action(active), + "checkout_provider_status": self.monetization.checkout_provider_status(self._billing_provider()), + "customer_portal_available": bool(stripe_customer_id), + "customer_id": stripe_customer_id, + "effective_tier": (effective_provider or {}).get("tier_id") or (active or {}).get("tier_id"), + "provider_subscriptions": provider_subscriptions, + "provider_source_summary": self._provider_source_summary(account_id=account_id), + "refund_dispute_summary": self._refund_dispute_summary(account_id=account_id), + **security_state, **self._config_snapshot(), } - def start_checkout(self, *, account_id: str, tier_id: str, provider: str = "web_stub") -> Dict[str, Any]: + def start_checkout( + self, + *, + account_id: str, + tier_id: str, + provider: Optional[str] = None, + customer_email: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + success_url: Optional[str] = None, + cancel_url: Optional[str] = None, + ) -> Dict[str, Any]: restriction = self._active_restriction_for_scope(account_id, scope="checkout") if restriction: raise ValueError("checkout_restricted") - checkout = self.monetization.start_checkout(account_id=account_id, tier_id=tier_id, provider=provider) + self._require_paid_pilot_checkout_invite(account_id=account_id) + self._require_verified_email_for_billing(account_id=account_id) + resolved_provider = str(provider or self._billing_provider()) + try: + checkout = self.monetization.start_checkout( + account_id=account_id, + tier_id=tier_id, + provider=resolved_provider, + customer_email=customer_email, + metadata=metadata, + success_url=success_url, + cancel_url=cancel_url, + ) + except RuntimeError as exc: + raise ValueError(str(exc)) from exc expires_at = (self._utcnow() + timedelta(minutes=self._checkout_session_ttl_minutes())).isoformat() checkout_session = self.repository.save_billing_checkout_session( { "checkout_session_id": checkout["session_id"], "account_id": account_id, + "checkout_kind": "subscription", "tier_id": tier_id, - "provider": provider, - "provider_ref": checkout["session_id"], + "provider": resolved_provider, + "provider_ref": checkout.get("provider_ref") or checkout["session_id"], "status": "created", "checkout_url": checkout.get("checkout_url"), - "idempotency_key": f"{provider}:{account_id}:{tier_id}:{checkout['session_id']}", + "idempotency_key": f"{resolved_provider}:{account_id}:{tier_id}:{checkout['session_id']}", "expires_at": expires_at, } ) self._record_lifecycle_event( { "event_type": "checkout_session_created", - "provider": provider, - "provider_event_id": f"{provider}:{checkout['session_id']}:created", + "provider": resolved_provider, + "provider_event_id": f"{resolved_provider}:{checkout['session_id']}:created", "account_id": account_id, "checkout_session_id": checkout_session["checkout_session_id"], "status": "processed", @@ -787,6 +1345,264 @@ def start_checkout(self, *, account_id: str, tier_id: str, provider: str = "web_ "session_id": checkout_session["checkout_session_id"], } + def start_ink_checkout( + self, + *, + account_id: str, + package_id: str, + provider: Optional[str] = None, + customer_email: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + success_url: Optional[str] = None, + cancel_url: Optional[str] = None, + ) -> Dict[str, Any]: + restriction = self._active_restriction_for_scope(account_id, scope="checkout") + if restriction: + raise ValueError("checkout_restricted") + self._require_paid_pilot_checkout_invite(account_id=account_id) + self._require_verified_email_for_billing(account_id=account_id) + package = self.monetization.get_ink_package(package_id) + resolved_provider = str(provider or self._billing_provider()) + try: + checkout = self.monetization.start_ink_checkout( + account_id=account_id, + package_id=str(package.get("package_id") or package_id), + provider=resolved_provider, + customer_email=customer_email, + metadata=metadata, + success_url=success_url, + cancel_url=cancel_url, + ) + except RuntimeError as exc: + raise ValueError(str(exc)) from exc + expires_at = (self._utcnow() + timedelta(minutes=self._checkout_session_ttl_minutes())).isoformat() + checkout_session = self.repository.save_billing_checkout_session( + { + "checkout_session_id": checkout["session_id"], + "account_id": account_id, + "checkout_kind": "ink", + "tier_id": "story_credits", + "package_id": str(package.get("package_id") or package_id), + "provider": resolved_provider, + "provider_ref": checkout.get("provider_ref") or checkout["session_id"], + "status": "created", + "checkout_url": checkout.get("checkout_url"), + "idempotency_key": f"{resolved_provider}:{account_id}:ink:{package.get('package_id') or package_id}:{checkout['session_id']}", + "expires_at": expires_at, + } + ) + self._record_lifecycle_event( + { + "event_type": "checkout_session_created", + "provider": resolved_provider, + "provider_event_id": f"{resolved_provider}:{checkout['session_id']}:created", + "account_id": account_id, + "checkout_session_id": checkout_session["checkout_session_id"], + "status": "processed", + "payload_json": { + **checkout_session, + "checkout_kind": "ink", + "package_id": str(package.get("package_id") or package_id), + "wallet_type": "story_credits", + "package_amount": package.get("amount"), + "package_bonus": package.get("bonus"), + }, + "processing_result": {"checkout_session_status": checkout_session["status"]}, + "occurred_at": self._utcnow().isoformat(), + "processed_at": self._utcnow().isoformat(), + } + ) + return { + **checkout_session, + "session_id": checkout_session["checkout_session_id"], + "package_id": str(package.get("package_id") or package_id), + "amount": float(package.get("amount") or 0.0), + "bonus": float(package.get("bonus") or 0.0), + "price_usd": float(package.get("price_usd") or 0.0), + } + + def complete_checkout_session( + self, + *, + checkout_session_id: str, + account_id: Optional[str] = None, + ) -> Dict[str, Any]: + try: + remote_checkout = self.monetization.retrieve_checkout_session( + checkout_session_id=checkout_session_id, + provider="stripe", + ) + except RuntimeError as exc: + raise ValueError(str(exc)) from exc + metadata = dict(remote_checkout.get("metadata") or {}) + resolved_account_id = str(account_id or "").strip() or None + remote_account_id = str( + metadata.get("account_id") + or remote_checkout.get("client_reference_id") + or resolved_account_id + or "" + ).strip() + if not remote_account_id: + raise ValueError("stripe_checkout_account_missing") + if resolved_account_id and resolved_account_id != remote_account_id: + raise PermissionError("checkout_session_account_mismatch") + + existing_checkout = None + try: + existing_checkout = self.repository.get_billing_checkout_session(checkout_session_id) + except KeyError: + existing_checkout = None + if existing_checkout and str(existing_checkout.get("account_id") or "") not in {"", remote_account_id}: + raise PermissionError("checkout_session_account_mismatch") + + tier_id = str( + (existing_checkout or {}).get("tier_id") + or metadata.get("tier_id") + or "" + ).strip() + checkout_kind = str( + (existing_checkout or {}).get("checkout_kind") + or metadata.get("checkout_kind") + or ("ink" if metadata.get("package_id") else "subscription") + ).strip() or "subscription" + package_id = str( + (existing_checkout or {}).get("package_id") + or metadata.get("package_id") + or "" + ).strip() or None + if checkout_kind == "subscription" and not tier_id: + raise ValueError("stripe_checkout_tier_missing") + if checkout_kind == "ink" and not package_id: + raise ValueError("stripe_checkout_package_missing") + resolved_tier_id = tier_id or "story_credits" + + checkout_status = self._stripe_checkout_status(remote_checkout) + checkout_record = self._upsert_checkout_session_record( + checkout_session_id=checkout_session_id, + account_id=remote_account_id, + tier_id=resolved_tier_id, + provider="stripe", + status=checkout_status, + checkout_kind=checkout_kind, + package_id=package_id, + provider_ref=str(remote_checkout.get("id") or checkout_session_id), + checkout_url=remote_checkout.get("url"), + expires_at=self._timestamp_to_iso(remote_checkout.get("expires_at")), + ) + + customer_id = str(remote_checkout.get("customer") or "").strip() or None + subscription_ref = str(remote_checkout.get("subscription") or "").strip() or None + processed_event = None + subscription = None + + if checkout_status == "completed" and subscription_ref: + processed_event = self._process_lifecycle_event( + { + "event_type": "checkout_session_completed", + "provider": "stripe", + "provider_event_id": f"stripe_reconcile:{checkout_session_id}:completed", + "account_id": remote_account_id, + "subscription_id": subscription_ref, + "checkout_session_id": checkout_session_id, + "payload_json": { + "source": "checkout_completion_reconcile", + "stripe_event_type": "checkout.session.completed", + "account_id": remote_account_id, + "customer_id": customer_id, + "subscription_id": subscription_ref, + "checkout_session_id": checkout_session_id, + "status": remote_checkout.get("status"), + "metadata": metadata, + }, + "occurred_at": self._timestamp_to_iso(remote_checkout.get("created")) or self._utcnow().isoformat(), + } + ) + try: + remote_subscription = self.monetization.retrieve_subscription( + subscription_ref=subscription_ref, + provider="stripe", + ) + except RuntimeError as exc: + raise ValueError(str(exc)) from exc + subscription = self._reconcile_stripe_subscription_record( + account_id=remote_account_id, + tier_id=resolved_tier_id, + stripe_subscription=remote_subscription, + local_subscription_id=processed_event.get("processing_result", {}).get("subscription_id"), + ) + checkout_record = self._upsert_checkout_session_record( + checkout_session_id=checkout_session_id, + account_id=remote_account_id, + tier_id=resolved_tier_id, + provider="stripe", + status="completed", + checkout_kind=checkout_kind, + package_id=package_id, + provider_ref=str(remote_checkout.get("id") or checkout_session_id), + checkout_url=remote_checkout.get("url"), + subscription_id=subscription["subscription_id"], + expires_at=self._timestamp_to_iso(remote_checkout.get("expires_at")), + ) + elif checkout_status == "expired": + processed_event = self._process_lifecycle_event( + { + "event_type": "checkout_session_expired", + "provider": "stripe", + "provider_event_id": f"stripe_reconcile:{checkout_session_id}:expired", + "account_id": remote_account_id, + "checkout_session_id": checkout_session_id, + "payload_json": { + "source": "checkout_completion_reconcile", + "stripe_event_type": "checkout.session.expired", + "account_id": remote_account_id, + "customer_id": customer_id, + "checkout_session_id": checkout_session_id, + "status": remote_checkout.get("status"), + "metadata": metadata, + }, + "occurred_at": self._timestamp_to_iso(remote_checkout.get("created")) or self._utcnow().isoformat(), + } + ) + elif checkout_status == "completed" and checkout_kind == "ink": + processed_event = self._process_lifecycle_event( + { + "event_type": "checkout_session_completed", + "provider": "stripe", + "provider_event_id": f"stripe_reconcile:{checkout_session_id}:completed", + "account_id": remote_account_id, + "checkout_session_id": checkout_session_id, + "payload_json": { + "source": "checkout_completion_reconcile", + "stripe_event_type": "checkout.session.completed", + "account_id": remote_account_id, + "customer_id": customer_id, + "checkout_session_id": checkout_session_id, + "status": remote_checkout.get("status"), + "metadata": metadata, + "checkout_kind": "ink", + "package_id": package_id, + }, + "occurred_at": self._timestamp_to_iso(remote_checkout.get("created")) or self._utcnow().isoformat(), + } + ) + checkout_record = self.repository.get_billing_checkout_session(checkout_session_id) + elif checkout_record.get("subscription_id"): + try: + subscription = self.monetization.reconcile_subscription_lifecycle(checkout_record["subscription_id"]) + except KeyError: + subscription = None + + return { + "account_id": remote_account_id, + "checkout": checkout_record, + "subscription": self._subscription_snapshot(subscription) if subscription else None, + "wallet": self._wallets_for_account(remote_account_id).get("story_credits") if checkout_kind == "ink" else None, + "customer_id": customer_id or self._stripe_customer_id_for_account(remote_account_id), + "customer_portal_available": bool(customer_id or self._stripe_customer_id_for_account(remote_account_id)), + "processed_event": processed_event, + "remote_checkout_status": checkout_status, + } + def _process_lifecycle_event(self, event: Dict[str, Any], *, replay: bool = False) -> Dict[str, Any]: existing = self.repository.get_billing_lifecycle_event_by_provider_ref( provider=event["provider"], @@ -820,7 +1636,43 @@ def _process_lifecycle_event(self, event: Dict[str, Any], *, replay: bool = Fals } elif event_type == "checkout_session_completed": checkout_session = self.repository.get_billing_checkout_session(str(checkout_session_id)) - if checkout_session.get("subscription_id"): + if checkout_session.get("checkout_kind") == "ink": + package_id = str(checkout_session.get("package_id") or "").strip() + if not package_id: + raise KeyError("ink_checkout_package_required") + if checkout_session.get("fulfilled_at"): + wallet = self._wallets_for_account(checkout_session["account_id"]).get("story_credits") + processing_result = { + "applied": False, + "package_id": package_id, + "checkout_session_status": checkout_session["status"], + "wallet_type": "story_credits", + "wallet_balance": float((wallet or {}).get("balance") or 0.0), + } + else: + package = self.monetization.get_ink_package(package_id) + granted_units = float(package.get("amount") or 0.0) + float(package.get("bonus") or 0.0) + wallet = self.grant_wallet_credits( + account_id=checkout_session["account_id"], + wallet_type=str(package.get("wallet_type") or "story_credits"), + amount=granted_units, + ) + checkout_session = self.repository.save_billing_checkout_session( + { + **checkout_session, + "status": "completed", + "fulfilled_at": self._utcnow().isoformat(), + } + ) + processing_result = { + "applied": True, + "package_id": package_id, + "checkout_session_status": checkout_session["status"], + "wallet_type": str(package.get("wallet_type") or "story_credits"), + "granted_units": granted_units, + "wallet_balance": float(wallet.get("balance") or 0.0), + } + elif checkout_session.get("subscription_id"): subscription = self.repository.get_subscription(checkout_session["subscription_id"]) elif existing and existing.get("processing_result", {}).get("subscription_id"): subscription = self.repository.get_subscription(existing["processing_result"]["subscription_id"]) @@ -829,21 +1681,21 @@ def _process_lifecycle_event(self, event: Dict[str, Any], *, replay: bool = Fals account_id=checkout_session["account_id"], tier_id=checkout_session["tier_id"], provider=checkout_session["provider"], - provider_ref=checkout_session["provider_ref"], + provider_ref=event_record.get("subscription_id") or checkout_session["provider_ref"], status="active", ) - checkout_session = self.repository.save_billing_checkout_session( - { - **checkout_session, + checkout_session = self.repository.save_billing_checkout_session( + { + **checkout_session, + "subscription_id": subscription["subscription_id"], + "status": "completed", + } + ) + processing_result = { + "applied": True, "subscription_id": subscription["subscription_id"], - "status": "completed", + "checkout_session_status": checkout_session["status"], } - ) - processing_result = { - "applied": True, - "subscription_id": subscription["subscription_id"], - "checkout_session_status": checkout_session["status"], - } elif event_type == "checkout_session_expired": checkout_session = self.repository.get_billing_checkout_session(str(checkout_session_id)) checkout_session = self.repository.save_billing_checkout_session( @@ -923,6 +1775,12 @@ def _process_lifecycle_event(self, event: Dict[str, Any], *, replay: bool = Fals "subscription_id": subscription["subscription_id"], "subscription_status": subscription["status"], } + elif event_type in {"payment_refunded", "charge_disputed", "charge_dispute_closed"}: + processing_result = { + "applied": False, + "subscription_id": subscription.get("subscription_id") if subscription else None, + "subscription_status": subscription.get("status") if subscription else None, + } else: raise ValueError("unsupported_billing_event_type") @@ -950,6 +1808,229 @@ def ingest_checkout_webhook(self, payload: Dict[str, Any]) -> Dict[str, Any]: processed = self._process_lifecycle_event(event) return {"event": processed} + def ingest_stripe_webhook(self, *, raw_body: bytes, signature: str) -> Dict[str, Any]: + webhook_secret = self._stripe_webhook_secret() + if not webhook_secret: + raise ValueError("stripe_webhook_not_configured") + try: + import stripe # type: ignore + except ModuleNotFoundError as exc: + raise ValueError("stripe_sdk_missing") from exc + stripe.api_key = os.getenv("NARRATIVEOS_STRIPE_SECRET_KEY") + try: + stripe_event = stripe.Webhook.construct_event(payload=raw_body, sig_header=signature, secret=webhook_secret) + except Exception as exc: # pragma: no cover - exact exception types depend on stripe SDK + raise ValueError("stripe_webhook_signature_invalid") from exc + + stripe_event_payload = self._stripe_payload(stripe_event) + event_type = str(stripe_event_payload.get("type") or "") + payload = dict(dict(stripe_event_payload.get("data") or {}).get("object") or {}) + metadata = dict(payload.get("metadata") or {}) + subscription_ref = ( + payload.get("subscription") + or dict(payload.get("lines") or {}).get("data", [{}])[0].get("subscription") + or dict(payload.get("parent") or {}).get("subscription_details", {}).get("subscription") + ) + account_id = metadata.get("account_id") or payload.get("client_reference_id") + customer_id = payload.get("customer") + + mapped_type = None + if event_type == "checkout.session.completed": + mapped_type = "checkout_session_completed" + elif event_type == "checkout.session.expired": + mapped_type = "checkout_session_expired" + elif event_type == "invoice.payment_failed": + mapped_type = "subscription_payment_failed" + elif event_type == "invoice.paid": + mapped_type = "subscription_payment_succeeded" + elif event_type == "customer.subscription.deleted": + mapped_type = "subscription_canceled" + elif event_type == "customer.subscription.updated": + status = str(payload.get("status") or "") + if status in {"active", "trialing"}: + mapped_type = "subscription_payment_succeeded" + elif status in {"past_due", "unpaid", "incomplete"}: + mapped_type = "subscription_past_due" + elif status in {"canceled", "incomplete_expired"}: + mapped_type = "subscription_canceled" + elif event_type in {"charge.refunded", "refund.updated"}: + mapped_type = "payment_refunded" + elif event_type == "charge.dispute.created": + mapped_type = "charge_disputed" + elif event_type in {"charge.dispute.closed", "charge.dispute.funds_reinstated"}: + mapped_type = "charge_dispute_closed" + + if not mapped_type: + return {"ignored": True, "event_type": event_type} + + created = payload.get("created") or stripe_event_payload.get("created") + occurred_at = self._utcnow().isoformat() + if created: + try: + occurred_at = datetime.fromtimestamp(int(created), tz=timezone.utc).isoformat() + except (TypeError, ValueError): + pass + event = { + "event_type": mapped_type, + "provider": "stripe", + "provider_event_id": str(stripe_event_payload.get("id") or f"stripe:{event_type}:{subscription_ref or payload.get('id') or 'unknown'}"), + "account_id": account_id, + "subscription_id": subscription_ref, + "checkout_session_id": payload.get("id") if str(payload.get("object") or "") == "checkout.session" else None, + "payload_json": { + "stripe_event_type": event_type, + "account_id": account_id, + "customer_id": customer_id, + "subscription_id": subscription_ref, + "checkout_session_id": payload.get("id") if str(payload.get("object") or "") == "checkout.session" else None, + "status": payload.get("status"), + "metadata": metadata, + }, + "occurred_at": occurred_at, + } + processed = self._process_lifecycle_event(event) + return {"event": processed, "stripe_event_type": event_type} + + def start_customer_portal(self, *, account_id: str, return_url: Optional[str] = None) -> Dict[str, Any]: + customer_id = self._stripe_customer_id_for_account(account_id) + if not customer_id: + raise KeyError("stripe_customer_id_missing_for_account") + try: + portal = self.monetization.start_customer_portal( + account_id=account_id, + customer_id=customer_id, + provider=self._billing_provider(), + return_url=return_url, + ) + except RuntimeError as exc: + raise ValueError(str(exc)) from exc + return { + **portal, + "account_id": account_id, + } + + def verify_mobile_purchase(self, *, provider: str, account_id: str, payload: Dict[str, Any]) -> Dict[str, Any]: + self._require_verified_email_for_billing(account_id=account_id) + try: + verified = self.monetization.verify_mobile_purchase(provider=provider, payload=payload) + except RuntimeError as exc: + raise ValueError(str(exc)) from exc + reconciled = self._reconcile_external_provider_subscription( + account_id=account_id, + provider_payload=verified, + ) + event = self._record_lifecycle_event( + { + "event_type": "mobile_purchase_verified", + "provider": provider, + "provider_event_id": f"{provider}:verify:{verified.get('provider_ref') or verified.get('provider_order_id') or int(self._utcnow().timestamp())}", + "account_id": account_id, + "subscription_id": (reconciled.get("effective_subscription") or {}).get("subscription_id"), + "status": "processed", + "payload_json": dict(verified.get("payload_json") or {}), + "processing_result": reconciled, + "occurred_at": self._utcnow().isoformat(), + "processed_at": self._utcnow().isoformat(), + } + ) + return { + **reconciled, + "event": event, + } + + def ingest_store_notification(self, *, provider: str, payload: Dict[str, Any]) -> Dict[str, Any]: + try: + parsed = self.monetization.ingest_store_notification(provider=provider, payload=payload) + except RuntimeError as exc: + raise ValueError(str(exc)) from exc + account_id = str( + payload.get("account_id") + or dict(parsed.get("payload_json") or {}).get("account_id") + or dict(parsed.get("payload_json") or {}).get("transaction_info", {}).get("appAccountToken") + or "" + ).strip() + if not account_id: + raise ValueError("provider_notification_account_missing") + reconciled = self._reconcile_external_provider_subscription( + account_id=account_id, + provider_payload=parsed, + ) + event = self._record_lifecycle_event( + { + "event_type": "store_notification_processed", + "provider": provider, + "provider_event_id": f"{provider}:notification:{parsed.get('provider_event_type')}:{parsed.get('provider_ref') or parsed.get('provider_order_id') or int(self._utcnow().timestamp())}", + "account_id": account_id, + "subscription_id": (reconciled.get("effective_subscription") or {}).get("subscription_id"), + "status": "processed", + "payload_json": dict(parsed.get("payload_json") or {}), + "processing_result": reconciled, + "occurred_at": self._utcnow().isoformat(), + "processed_at": self._utcnow().isoformat(), + } + ) + return { + **reconciled, + "event": event, + } + + def restore_mobile_purchases(self, *, account_id: str, payload: Dict[str, Any]) -> Dict[str, Any]: + self._require_verified_email_for_billing(account_id=account_id) + restored: List[Dict[str, Any]] = [] + for original_transaction_id in list(payload.get("apple_original_transaction_ids") or []): + restored.append( + { + "provider": "app_store", + **self.verify_mobile_purchase( + provider="app_store", + account_id=account_id, + payload={ + "original_transaction_id": original_transaction_id, + "tier_id": payload.get("apple_tier_id"), + "environment": payload.get("apple_environment"), + }, + ), + } + ) + for purchase_token in list(payload.get("google_purchase_tokens") or []): + restored.append( + { + "provider": "google_play", + **self.verify_mobile_purchase( + provider="google_play", + account_id=account_id, + payload={ + "purchase_token": purchase_token, + "subscription_id": payload.get("google_subscription_id"), + "package_name": payload.get("package_name"), + "tier_id": payload.get("google_tier_id"), + "environment": payload.get("google_environment"), + }, + ), + } + ) + return { + "account_id": account_id, + "restored": restored, + "provider_subscriptions": self._list_provider_subscriptions(account_id=account_id), + "effective_tier": (self._effective_provider_subscription(account_id=account_id) or {}).get("tier_id"), + } + + def reconcile_account_billing(self, *, account_id: str, provider: Optional[str] = None) -> Dict[str, Any]: + if provider in {None, "", "stripe"}: + subscription = self._find_subscription_for_event(account_id=account_id, subscription_id=None) + if subscription and subscription.get("provider") == "stripe" and subscription.get("provider_ref"): + self.reconcile_subscription(subscription["subscription_id"]) + effective = self._sync_effective_subscription_from_provider_records(account_id=account_id) + return { + "account_id": account_id, + "provider": provider or "all", + "effective_subscription": self._subscription_snapshot(effective) if effective else None, + "provider_subscriptions": self._list_provider_subscriptions(account_id=account_id), + "provider_source_summary": self._provider_source_summary(account_id=account_id), + "refund_dispute_summary": self._refund_dispute_summary(account_id=account_id), + } + def retry_subscription_payment(self, *, account_id: Optional[str] = None, subscription_id: Optional[str] = None) -> Dict[str, Any]: subscription = self._find_subscription_for_event(account_id=account_id, subscription_id=subscription_id) if subscription is None: @@ -1023,6 +2104,22 @@ def cancel_subscription(self, *, account_id: str) -> Dict[str, Any]: return {"event": processed} def reconcile_subscription(self, subscription_id: str) -> Dict[str, Any]: + subscription = self.repository.get_subscription(subscription_id) + remote_payload = None + if subscription.get("provider") == "stripe" and subscription.get("provider_ref"): + try: + remote_payload = self.monetization.retrieve_subscription( + subscription_ref=str(subscription["provider_ref"]), + provider="stripe", + ) + except RuntimeError as exc: + raise ValueError(str(exc)) from exc + subscription = self._reconcile_stripe_subscription_record( + account_id=subscription["account_id"], + tier_id=subscription["tier_id"], + stripe_subscription=remote_payload, + local_subscription_id=subscription["subscription_id"], + ) reconciled = self.monetization.reconcile_subscription_lifecycle(subscription_id) if reconciled.get("status") in {"past_due", "canceled", "expired"}: wallet_snapshot = self._deactivate_subscription_wallets( @@ -1042,7 +2139,12 @@ def reconcile_subscription(self, subscription_id: str) -> Dict[str, Any]: "account_id": reconciled["account_id"], "subscription_id": subscription_id, "status": "processed", - "payload_json": {"source": "manual_reconcile"}, + "payload_json": { + "source": "manual_reconcile", + "customer_id": (remote_payload or {}).get("customer"), + "provider_ref": reconciled.get("provider_ref"), + "remote_status": (remote_payload or {}).get("status"), + }, "processing_result": { "subscription_status": reconciled["status"], "wallet_snapshot": wallet_snapshot, @@ -1846,6 +2948,7 @@ def account_detail(self, *, account_id: str, limit: int = 10) -> Dict[str, Any]: audit_bundle = self.full_audit_trail(account_id=account_id, limit=max(limit * 2, 20)) support_lookup = self.support_issue_lookup(account_id=account_id, limit=limit) checkout_sessions = self.repository.list_billing_checkout_sessions(account_id=account_id, limit=limit) + provider_subscriptions = self._list_provider_subscriptions(account_id=account_id) lifecycle_events = self.repository.list_billing_lifecycle_events(account_id=account_id, limit=limit) retry_attempts = self.repository.list_billing_retry_attempts(account_id=account_id, limit=limit) recent_meters = self.repository.list_usage_meters(account_id=account_id)[:limit] @@ -1937,9 +3040,13 @@ def account_detail(self, *, account_id: str, limit: int = 10) -> Dict[str, Any]: return { "account_id": account_id, "subscription": subscription_snapshot.get("subscription"), + "effective_tier": subscription_snapshot.get("effective_tier"), "wallets": subscription_snapshot.get("wallets", {}), "checkout_session": subscription_snapshot.get("latest_checkout_session"), "recent_checkout_sessions": checkout_sessions, + "provider_subscriptions": provider_subscriptions, + "provider_source_summary": subscription_snapshot.get("provider_source_summary", {}), + "refund_dispute_summary": subscription_snapshot.get("refund_dispute_summary", {}), "lifecycle_history_summary": subscription_snapshot.get("lifecycle_history_summary", {}), "billing_lifecycle_events": lifecycle_events, "billing_retry_attempts": retry_attempts, @@ -1956,6 +3063,7 @@ def account_detail(self, *, account_id: str, limit: int = 10) -> Dict[str, Any]: "recent_sessions": recent_sessions, "recent_drafts": recent_drafts, "activity_summary": activity_summary, + "security_state": self._account_security_state(account_id=account_id), **self._config_snapshot(), } diff --git a/src/narrativeos/services/choice_semantics.py b/src/narrativeos/services/choice_semantics.py new file mode 100644 index 0000000..a10e476 --- /dev/null +++ b/src/narrativeos/services/choice_semantics.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from typing import Any, Dict, Iterable, List, Optional + + +def _normalized_text(value: Any) -> str: + return str(value or "").strip() + + +def _contains_any(text: str, keywords: Iterable[str]) -> bool: + normalized = text.lower() + return any(str(keyword).lower() in normalized for keyword in keywords) + + +def _choice_label(text: str) -> str: + normalized = _normalized_text(text) + if not normalized: + return "继续推进" + for marker in (":", ":", ",", ",", "。", ".", "\n"): + if marker in normalized: + normalized = normalized.split(marker, 1)[0].strip() + return normalized[:18] or "继续推进" + + +def _risk_level(text: str) -> tuple[str, str]: + if _contains_any( + text, + ( + "揭露", + "摊牌", + "追查", + "闯入", + "对抗", + "暴露", + "反派", + "危险", + "逼问", + "betray", + "confront", + "expose", + ), + ): + return "高", "可能让身份、秘密或关系压力提前暴露" + if _contains_any( + text, + ( + "保护", + "等待", + "观察", + "暂缓", + "隐藏", + "退后", + "守住", + "delay", + "protect", + "wait", + ), + ): + return "低", "更偏向保留余地,但线索推进会放慢" + return "中", "会改变下一章压力,但仍保留回旋空间" + + +def _emotion(text: str) -> str: + if _contains_any(text, ("争执", "对抗", "摊牌", "逼问", "冲突", "confront", "argue")): + return "冲突" + if _contains_any(text, ("相信", "靠近", "保护", "坦白", "升温", "trust", "protect", "confess")): + return "升温" + if _contains_any(text, ("隐瞒", "退后", "离开", "冷却", "疏远", "hide", "leave")): + return "冷却" + return "波动" + + +def _pacing(text: str) -> str: + if _contains_any(text, ("反转", "误会", "真相", "揭晓", "reverse", "twist")): + return "反转" + if _contains_any(text, ("等待", "观察", "隐藏", "保护", "放慢", "暂缓", "delay", "wait")): + return "暂缓" + if _contains_any(text, ("追查", "推进", "进入", "选择", "摊牌", "证据", "advance", "follow")): + return "推进" + return "推进" + + +def _relationship(text: str) -> str: + if _contains_any(text, ("背叛", "出卖", "反派", "betray")): + return "背叛" + if _contains_any(text, ("相信", "坦白", "保护", "靠近", "信任", "trust", "confess")): + return "信任" + if _contains_any(text, ("试探", "隐瞒", "误会", "调查", "怀疑", "hide", "doubt")): + return "怀疑" + return "拉扯" + + +def _mystery(text: str) -> str: + if _contains_any(text, ("隐瞒", "隐藏", "误会", "不要揭晓", "悬疑", "暂缓", "hide", "delay")): + return "加深" + if _contains_any(text, ("追查", "证据", "真相", "揭露", "揭晓", "evidence", "truth")): + return "揭开" + if _contains_any(text, ("反转", "转向", "twist", "turn")): + return "转向" + return "维持" + + +def _expected_effect(*, pacing: str, relationship: str, mystery: str) -> str: + if pacing == "反转": + return "制造转折并改变读者对局势的判断" + if mystery == "揭开": + return "推进主线线索并提高下一章行动压力" + if mystery == "加深": + return "保留真相并制造新的误会空间" + if relationship == "信任": + return "增强人物关系并让后续选择更有代价" + if relationship == "怀疑": + return "拉高关系不确定性并延后摊牌" + return "推动当前局势进入下一段选择压力" + + +def build_choice_impacts( + choices: Iterable[Any], + *, + reader_view: Optional[Dict[str, Any]] = None, + routes: Optional[Iterable[Any]] = None, + chapter_index: int = 0, +) -> List[Dict[str, Any]]: + """Build product-language impact tags without pack-specific prose rules.""" + _ = reader_view, routes + impacts: List[Dict[str, Any]] = [] + for index, raw_choice in enumerate(list(choices or []), start=1): + text = _normalized_text(raw_choice) + label = _choice_label(text) + risk_level, risk_reason = _risk_level(text) + emotion = _emotion(text) + pacing = _pacing(text) + relationship = _relationship(text) + mystery = _mystery(text) + expected_effect = _expected_effect(pacing=pacing, relationship=relationship, mystery=mystery) + choice_id = f"choice_{int(chapter_index or 0)}_{index}" if int(chapter_index or 0) > 0 else f"choice_{index}" + impacts.append( + { + "choice_id": choice_id, + "label": label, + "expected_effect": expected_effect, + "risk_level": risk_level, + "risk_reason": risk_reason, + "emotion": emotion, + "pacing": pacing, + "relationship": relationship, + "mystery": mystery, + "director_intent_prefill": f"沿着「{label}」继续,让下一章{expected_effect}。", + } + ) + return impacts + + +def merge_choice_impacts_into_reader_view( + reader_view: Dict[str, Any], + *, + routes: Optional[Iterable[Any]] = None, + chapter_index: int = 0, +) -> Dict[str, Any]: + payload = dict(reader_view or {}) + payload["choice_impacts"] = build_choice_impacts( + payload.get("choices") or [], + reader_view=payload, + routes=routes, + chapter_index=chapter_index or int(payload.get("chapter_index") or 0), + ) + return payload diff --git a/src/narrativeos/services/commercial_audit.py b/src/narrativeos/services/commercial_audit.py new file mode 100644 index 0000000..f00633c --- /dev/null +++ b/src/narrativeos/services/commercial_audit.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from collections import Counter +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from .customer_accounts import CustomerAccountService +from ..persistence.repositories import SQLAlchemyPlatformRepository + +if TYPE_CHECKING: + from .commercial_billing import CommercialBillingService + + +DEFAULT_RETENTION_POLICIES = ( + {"retention_policy_id": "retention_audit_logs_v1", "scope": "audit_logs", "retention_days": 365, "deletion_mode": "customer_request_review", "status": "active"}, + {"retention_policy_id": "retention_customer_exports_v1", "scope": "customer_audit_exports", "retention_days": 90, "deletion_mode": "customer_request_review", "status": "active"}, + {"retention_policy_id": "retention_deletion_requests_v1", "scope": "data_deletion_requests", "retention_days": 365, "deletion_mode": "manual_request", "status": "active"}, +) + + +class CommercialAuditService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + customer_account_service: CustomerAccountService, + commercial_billing_service: Optional["CommercialBillingService"] = None, + ) -> None: + self.repository = repository + self.customer_accounts = customer_account_service + self.commercial_billing = commercial_billing_service + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def sync_default_retention_policies(self) -> Dict[str, Any]: + policies = [self.repository.save_data_retention_policy(item) for item in DEFAULT_RETENTION_POLICIES] + return { + "policies": policies, + "summary": { + "policy_count": len(policies), + }, + } + + def _customer_context(self, *, account_id: str) -> Dict[str, Any]: + return self.customer_accounts.customer_account_detail(account_id=account_id) + + def customer_safe_payload(self, payload: Any) -> Any: + if isinstance(payload, dict): + safe: Dict[str, Any] = {} + for key, value in payload.items(): + if key in {"reviewer_id", "owner_id", "internal_payload_json", "support_payload_json", "dispute_payload_json", "adjustment_payload_json", "refund_payload_json", "resolution_note", "requested_by", "requested_by_actor", "raw_evidence", "internal_reason_codes", "top_reason_codes"}: + continue + if key == "latest_quality_events": + continue + if key == "reason_codes": + continue + safe[key] = self.customer_safe_payload(value) + return safe + if isinstance(payload, list): + return [self.customer_safe_payload(item) for item in payload] + return payload + + def record_audit_log( + self, + *, + actor_id: str, + actor_role: str, + account_id: Optional[str], + object_type: str, + object_id: str, + action_type: str, + source_surface: str, + customer_visible_payload: Optional[Dict[str, Any]] = None, + internal_payload: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + customer_account_id = None + if account_id: + customer = self.repository.get_customer_account_by_account_id(account_id, default=None) + customer_account_id = (customer or {}).get("customer_account_id") + return self.repository.save_audit_log( + { + "actor_id": actor_id, + "actor_role": actor_role, + "account_id": account_id, + "customer_account_id": customer_account_id, + "object_type": object_type, + "object_id": object_id, + "action_type": action_type, + "source_surface": source_surface, + "customer_visible_payload": self.customer_safe_payload(customer_visible_payload or {}), + "internal_payload": dict(internal_payload or {}), + } + ) + + def audit_log_listing( + self, + *, + account_id: Optional[str] = None, + customer_account_id: Optional[str] = None, + action_type: Optional[str] = None, + limit: int = 100, + ) -> Dict[str, Any]: + entries = self.repository.list_audit_logs( + account_id=account_id, + customer_account_id=customer_account_id, + action_type=action_type, + limit=limit, + ) + return { + "audit_logs": entries, + "summary": { + "entry_count": len(entries), + "by_action_type": dict(Counter(str(item.get("action_type") or "unknown") for item in entries)), + }, + } + + def customer_audit_export( + self, + *, + account_id: str, + requested_by: str, + period_start: Optional[str] = None, + period_end: Optional[str] = None, + ) -> Dict[str, Any]: + customer = self._customer_context(account_id=account_id)["customer_account"] + entries = self.repository.list_audit_logs(account_id=account_id, limit=500) + safe_entries = [ + { + **item, + "internal_payload_json": {}, + "customer_visible_payload_json": self.customer_safe_payload(item.get("customer_visible_payload_json") or {}), + } + for item in entries + ] + export_payload = { + "generated_at": self._utcnow(), + "account_id": account_id, + "customer_account_id": customer["customer_account_id"], + "period_start": period_start, + "period_end": period_end, + "audit_logs": safe_entries, + "summary": { + "entry_count": len(safe_entries), + "by_action_type": dict(Counter(str(item.get("action_type") or "unknown") for item in safe_entries)), + }, + } + saved = self.repository.save_customer_audit_export( + { + "customer_account_id": customer["customer_account_id"], + "account_id": account_id, + "requested_by": requested_by, + "period_start": period_start, + "period_end": period_end, + "export_payload": export_payload, + } + ) + return { + "audit_export": saved, + "export_payload": export_payload, + } + + def create_data_deletion_request( + self, + *, + account_id: str, + requested_by: str, + scope: str, + requested_payload: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + customer = self._customer_context(account_id=account_id)["customer_account"] + if self.commercial_billing is not None: + try: + self.commercial_billing.invoice_preview(account_id=account_id) + except Exception: + pass + affected_counts = { + "campaigns": len(self.repository.list_campaigns(account_id=account_id, limit=500)), + "invoice_previews": len(self.repository.list_invoice_previews(account_id=account_id, limit=500)), + "disputes": len(self.repository.list_disputes(account_id=account_id, limit=500)), + "support_cases": len(self.repository.list_support_cases(account_id=account_id, limit=500)), + "audit_logs": len(self.repository.list_audit_logs(account_id=account_id, limit=500)), + } + return self.repository.save_data_deletion_request( + { + "customer_account_id": customer["customer_account_id"], + "account_id": account_id, + "requested_by": requested_by, + "scope": scope, + "status": "requested", + "requested_payload": dict(requested_payload or {}), + "affected_object_counts": affected_counts, + } + ) diff --git a/src/narrativeos/services/commercial_billing.py b/src/narrativeos/services/commercial_billing.py new file mode 100644 index 0000000..f191b8c --- /dev/null +++ b/src/narrativeos/services/commercial_billing.py @@ -0,0 +1,464 @@ +from __future__ import annotations + +from collections import Counter +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Tuple + +from ..commercialization.config import load_billing_metering +from ..persistence.repositories import SQLAlchemyPlatformRepository +from .billing import BillingService +from .commercial_audit import CommercialAuditService +from .customer_accounts import CustomerAccountService +from .observability import ObservabilityService +from .ops_quality_projection import OpsQualityProjectionService + + +COMMERCIAL_BILLABLE_METRICS = frozenset({"validated_presented", "validated_handoff", "validated_conversion"}) +COMMERCIAL_BILLABLE_STATUSES = frozenset({"recorded", "approved", "disputed", "credited", "reversed", "refunded"}) + + +class CommercialBillingService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + billing_service: BillingService, + customer_account_service: CustomerAccountService, + audit_service: CommercialAuditService, + observability_service: ObservabilityService, + quality_projection_service: OpsQualityProjectionService, + ) -> None: + self.repository = repository + self.billing = billing_service + self.customer_accounts = customer_account_service + self.audit = audit_service + self.observability = observability_service + self.quality_projection = quality_projection_service + + def _utcnow(self) -> datetime: + return datetime.now(timezone.utc) + + def _period_bounds(self, *, period_start: Optional[str] = None) -> Tuple[str, str, str]: + if period_start: + normalized = str(period_start).replace("Z", "+00:00") + start_dt = datetime.fromisoformat(normalized) + if start_dt.tzinfo is None: + start_dt = start_dt.replace(tzinfo=timezone.utc) + start_dt = start_dt.astimezone(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0) + else: + now = self._utcnow() + start_dt = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + next_month = (start_dt.replace(day=28) + timedelta(days=4)).replace(day=1) + period_key = start_dt.strftime("%Y%m") + return start_dt.isoformat(), next_month.isoformat(), period_key + + def _config(self) -> Dict[str, Any]: + return load_billing_metering() + + def _metric_config(self, metric_type: str, plan_id: str) -> Dict[str, Any]: + config = self._config() + metrics = dict(config.get("metrics") or {}) + metric = dict(metrics.get(metric_type) or {}) + if not metric: + raise KeyError("unknown_billable_metric:%s" % metric_type) + included = float(dict(metric.get("included_units") or {}).get(plan_id, 0) or 0) + unit_price = float(dict(metric.get("unit_price_usd") or {}).get(plan_id, 0.0) or 0.0) + return { + "metric_type": metric_type, + "display_name": metric.get("display_name") or metric_type, + "included_units": included, + "unit_price_usd": unit_price, + "config_version": config.get("config_version"), + } + + def _credit_balance(self, *, account_id: str, customer_account_id: Optional[str]) -> Dict[str, Any]: + balance_type = str((self._config().get("credit_policy") or {}).get("default_balance_type") or "commercial_credit_usd") + balances = self.repository.list_credit_balances(account_id=account_id, balance_type=balance_type) + if balances: + return balances[0] + return self.repository.save_credit_balance( + { + "credit_balance_id": f"credit_balance_{account_id}", + "account_id": account_id, + "customer_account_id": customer_account_id, + "balance_type": balance_type, + "amount_usd": 0.0, + "source_ref": {"kind": "system_default", "account_id": account_id}, + } + ) + + def _trace_event_candidates(self, *, account_id: str) -> Tuple[List[Dict[str, Any]], Dict[str, Dict[str, Any]], Dict[str, Dict[str, Any]]]: + events = self.quality_projection.list_projected_quality_events(account_id=account_id, limit=1000) + feedback_items = self.quality_projection.list_projected_quality_feedback_items(account_id=account_id, limit=1000) + receipts = self.observability.list_runtime_receipts(account_id=account_id, limit=1000) + receipts_by_trace = { + str(item.get("trace_id") or "").strip(): item + for item in receipts + if str(item.get("trace_id") or "").strip() + } + feedback_by_trace: Dict[str, Dict[str, Any]] = {} + for item in feedback_items: + trace = str(item.get("trace_id") or "").strip() + if not trace: + continue + if trace not in feedback_by_trace: + feedback_by_trace[trace] = item + else: + current = feedback_by_trace[trace] + if str(item.get("created_at") or "") < str(current.get("created_at") or ""): + feedback_by_trace[trace] = item + return events, receipts_by_trace, feedback_by_trace + + def _billable_candidates(self, *, account_id: str, plan_id: str) -> List[Dict[str, Any]]: + events, receipts_by_trace, feedback_by_trace = self._trace_event_candidates(account_id=account_id) + billable: List[Dict[str, Any]] = [] + for event in events: + trace_id = str(event.get("trace_id") or "").strip() + if not trace_id: + continue + quality_event_id = str(event.get("event_id") or "").strip() + if str(event.get("status") or "") == "passed" and str(event.get("source_surface") or "") == "reader": + receipt = receipts_by_trace.get(trace_id) + if receipt and str(receipt.get("response_status") or "") == "ok": + metric = self._metric_config("validated_presented", plan_id) + billable.append( + { + "billable_event_id": f"billable_presented_{trace_id}", + "metric_type": "validated_presented", + "trace_id": trace_id, + "quality_event_id": quality_event_id, + "runtime_receipt_event_id": receipt.get("event_id"), + "feedback_item_id": None, + "source_surface": event.get("source_surface"), + "world_version_id": event.get("world_version_id"), + "session_id": event.get("session_id"), + "reason_codes": list(event.get("reason_codes") or []), + "quantity": 1.0, + "unit_price_usd": metric["unit_price_usd"], + "amount_usd": metric["unit_price_usd"], + "payload": { + "metric_config": metric, + "quality_status": event.get("status"), + "runtime_receipt_status": receipt.get("response_status"), + }, + } + ) + if str(event.get("status") or "") == "passed" and str(event.get("source_surface") or "") == "publish": + metric = self._metric_config("validated_handoff", plan_id) + billable.append( + { + "billable_event_id": f"billable_handoff_{trace_id}", + "metric_type": "validated_handoff", + "trace_id": trace_id, + "quality_event_id": quality_event_id, + "runtime_receipt_event_id": None, + "feedback_item_id": None, + "source_surface": event.get("source_surface"), + "world_version_id": event.get("world_version_id"), + "session_id": event.get("session_id"), + "reason_codes": list(event.get("reason_codes") or []), + "quantity": 1.0, + "unit_price_usd": metric["unit_price_usd"], + "amount_usd": metric["unit_price_usd"], + "payload": { + "metric_config": metric, + "quality_status": event.get("status"), + "handoff_kind": "publish_quality_passed", + }, + } + ) + positive_feedback = feedback_by_trace.get(trace_id) + if ( + str(event.get("status") or "") == "passed" + and positive_feedback + and str(positive_feedback.get("signal") or "") in {"explicit_positive", "positive_proxy"} + ): + metric = self._metric_config("validated_conversion", plan_id) + billable.append( + { + "billable_event_id": f"billable_conversion_{trace_id}", + "metric_type": "validated_conversion", + "trace_id": trace_id, + "quality_event_id": quality_event_id, + "runtime_receipt_event_id": None, + "feedback_item_id": positive_feedback.get("feedback_item_id"), + "source_surface": event.get("source_surface"), + "world_version_id": event.get("world_version_id"), + "session_id": event.get("session_id"), + "reason_codes": list(event.get("reason_codes") or []), + "quantity": 1.0, + "unit_price_usd": metric["unit_price_usd"], + "amount_usd": metric["unit_price_usd"], + "payload": { + "metric_config": metric, + "feedback_signal": positive_feedback.get("signal"), + "feedback_type": positive_feedback.get("feedback_type"), + }, + } + ) + deduped = {item["billable_event_id"]: item for item in billable} + return list(deduped.values()) + + def sync_account_billing(self, *, account_id: str, period_start: Optional[str] = None) -> Dict[str, Any]: + if self.repository.get_customer_account_by_account_id(account_id, default=None) is None: + self.customer_accounts.ensure_customer_account(account_id=account_id, display_name=account_id) + customer_detail = self.customer_accounts.customer_account_detail(account_id=account_id) + customer = dict(customer_detail.get("customer_account") or {}) + plan = dict(customer_detail.get("plan") or {}) + billing_period_start, billing_period_end, period_key = self._period_bounds(period_start=period_start) + ledger_id = f"usage_ledger_{account_id}_{period_key}" + customer_account_id = customer.get("customer_account_id") + plan_id = str(plan.get("plan_id") or customer.get("plan_id") or "play_pass") + billable_candidates = self._billable_candidates(account_id=account_id, plan_id=plan_id) + saved_billable_events: List[Dict[str, Any]] = [] + for candidate in billable_candidates: + existing = self.repository.get_billable_event(candidate["billable_event_id"], default=None) + status = str((existing or {}).get("status") or "recorded") + saved_billable_events.append( + self.repository.save_billable_event( + { + "billable_event_id": candidate["billable_event_id"], + "usage_ledger_id": ledger_id, + "account_id": account_id, + "customer_account_id": customer_account_id, + "plan_id": plan_id, + "billable_metric": candidate["metric_type"], + "status": status, + "trace_id": candidate["trace_id"], + "quality_event_id": candidate["quality_event_id"], + "runtime_receipt_event_id": candidate["runtime_receipt_event_id"], + "feedback_item_id": candidate["feedback_item_id"], + "source_surface": candidate["source_surface"], + "world_version_id": candidate["world_version_id"], + "session_id": candidate["session_id"], + "quantity": candidate["quantity"], + "unit_price_usd": candidate["unit_price_usd"], + "amount_usd": candidate["amount_usd"], + "reason_codes": candidate["reason_codes"], + "event_payload": candidate["payload"], + } + ) + ) + + metric_counts: Dict[str, int] = {key: 0 for key in COMMERCIAL_BILLABLE_METRICS} + subtotal = 0.0 + disputed = 0.0 + credited = 0.0 + reversed_amount = 0.0 + line_items: List[Dict[str, Any]] = [] + overage_flags: List[Dict[str, Any]] = [] + events_by_metric: Dict[str, List[Dict[str, Any]]] = {} + for item in saved_billable_events: + events_by_metric.setdefault(str(item.get("billable_metric") or ""), []).append(item) + for metric_type in COMMERCIAL_BILLABLE_METRICS: + metric_events = list(events_by_metric.get(metric_type) or []) + active_events = [item for item in metric_events if str(item.get("status") or "") not in {"disputed", "credited", "reversed", "refunded"}] + metric_counts[metric_type] = len(active_events) + metric_conf = self._metric_config(metric_type, plan_id) + included_units = float(metric_conf["included_units"]) + observed_units = float(len(active_events)) + billable_units = max(0.0, observed_units - included_units) + line_amount = round(billable_units * float(metric_conf["unit_price_usd"]), 6) + subtotal += line_amount + disputed += sum(float(item.get("amount_usd") or 0.0) for item in metric_events if str(item.get("status") or "") == "disputed") + credited += sum(float(item.get("amount_usd") or 0.0) for item in metric_events if str(item.get("status") or "") == "credited") + reversed_amount += sum(float(item.get("amount_usd") or 0.0) for item in metric_events if str(item.get("status") or "") == "reversed") + line_items.append( + { + "metric_type": metric_type, + "display_name": metric_conf["display_name"], + "observed_units": observed_units, + "included_units": included_units, + "billable_units": billable_units, + "unit_price_usd": metric_conf["unit_price_usd"], + "line_amount_usd": line_amount, + } + ) + flag_status = "active" if billable_units > 0 else "resolved" + overage_flags.append( + self.repository.save_overage_flag( + { + "overage_flag_id": f"overage_{account_id}_{metric_type}", + "account_id": account_id, + "customer_account_id": customer_account_id, + "plan_id": plan_id, + "metric_type": metric_type, + "status": flag_status, + "observed_units": observed_units, + "included_units": included_units, + "overage_units": billable_units, + "flag_payload": {"metric_config": metric_conf, "billing_period_start": billing_period_start}, + } + ) + ) + + ledger = self.repository.save_usage_ledger( + { + "usage_ledger_id": ledger_id, + "account_id": account_id, + "customer_account_id": customer_account_id, + "plan_id": plan_id, + "status": "open", + "billing_period_start": billing_period_start, + "billing_period_end": billing_period_end, + "presented_count": metric_counts["validated_presented"], + "handoff_count": metric_counts["validated_handoff"], + "conversion_count": metric_counts["validated_conversion"], + "subtotal_amount_usd": subtotal, + "disputed_amount_usd": disputed, + "credited_amount_usd": credited, + "reversed_amount_usd": reversed_amount, + "ledger_payload": { + "line_items": line_items, + "metric_counts": metric_counts, + }, + } + ) + credit_balance = self._credit_balance(account_id=account_id, customer_account_id=customer_account_id) + credits_applied = min(float(credit_balance.get("amount_usd") or 0.0), max(0.0, subtotal - disputed - credited - reversed_amount)) + invoice_preview = self.repository.save_invoice_preview( + { + "invoice_preview_id": f"invoice_preview_{account_id}_{period_key}", + "usage_ledger_id": ledger["usage_ledger_id"], + "account_id": account_id, + "customer_account_id": customer_account_id, + "plan_id": plan_id, + "status": "draft", + "billing_period_start": billing_period_start, + "billing_period_end": billing_period_end, + "subtotal_amount_usd": subtotal, + "credits_applied_usd": credits_applied, + "disputed_amount_usd": disputed, + "credited_amount_usd": credited, + "reversed_amount_usd": reversed_amount, + "total_due_usd": round(max(0.0, subtotal - disputed - credited - reversed_amount - credits_applied), 6), + "line_items": line_items, + "summary": { + "metric_counts": metric_counts, + "billable_event_count": len(saved_billable_events), + "credit_balance_usd": float(credit_balance.get("amount_usd") or 0.0), + "config_version": self._config().get("config_version"), + }, + } + ) + return { + "customer_account": customer, + "plan": plan, + "usage_ledger": ledger, + "billable_events": saved_billable_events, + "invoice_preview": invoice_preview, + "credit_balance": credit_balance, + "overage_flags": overage_flags, + } + + def invoice_preview(self, *, account_id: str, period_start: Optional[str] = None) -> Dict[str, Any]: + synced = self.sync_account_billing(account_id=account_id, period_start=period_start) + invoice_preview = dict(synced.get("invoice_preview") or {}) + billable_events = list(synced.get("billable_events") or []) + status_counts = Counter(str(item.get("status") or "unknown") for item in billable_events) + line_items = list(invoice_preview.get("line_items_json") or []) + csv_lines = [ + "metric_type,display_name,observed_units,included_units,billable_units,unit_price_usd,line_amount_usd" + ] + for item in line_items: + csv_lines.append( + ",".join( + [ + str(item.get("metric_type") or ""), + str(item.get("display_name") or ""), + str(item.get("observed_units") or 0), + str(item.get("included_units") or 0), + str(item.get("billable_units") or 0), + str(item.get("unit_price_usd") or 0), + str(item.get("line_amount_usd") or 0), + ] + ) + ) + result = { + "generated_at": self._utcnow().isoformat(), + "account_id": account_id, + "customer_account": synced.get("customer_account"), + "plan": synced.get("plan"), + "usage_ledger": synced.get("usage_ledger"), + "billable_events": billable_events, + "invoice_preview": invoice_preview, + "credit_balance": synced.get("credit_balance"), + "overage_flags": synced.get("overage_flags"), + "export_artifacts": { + "json_filename": f"invoice_preview_{account_id}.json", + "csv_filename": f"invoice_preview_{account_id}.csv", + "csv_preview": "\n".join(csv_lines), + }, + "summary": { + "billable_event_count": len(billable_events), + "status_counts": dict(status_counts), + "metrics": dict((invoice_preview.get("summary_json") or {}).get("metric_counts") or {}), + }, + } + self.audit.record_audit_log( + actor_id=account_id, + actor_role="customer", + account_id=account_id, + object_type="invoice_preview", + object_id=str((invoice_preview or {}).get("invoice_preview_id") or f"invoice_preview::{account_id}"), + action_type="invoice_preview_generated", + source_surface="customer", + customer_visible_payload={"invoice_preview": invoice_preview, "summary": result.get("summary")}, + internal_payload=result, + ) + return result + + def list_usage_ledgers(self, *, account_id: Optional[str] = None, limit: int = 50) -> Dict[str, Any]: + ledgers = self.repository.list_usage_ledgers(account_id=account_id, limit=limit) + return { + "usage_ledgers": ledgers, + "summary": { + "ledger_count": len(ledgers), + "subtotal_amount_usd": round(sum(float(item.get("subtotal_amount_usd") or 0.0) for item in ledgers), 6), + }, + } + + def list_invoice_previews(self, *, account_id: Optional[str] = None, limit: int = 50) -> Dict[str, Any]: + previews = self.repository.list_invoice_previews(account_id=account_id, limit=limit) + return { + "invoice_previews": previews, + "summary": { + "preview_count": len(previews), + "total_due_usd": round(sum(float(item.get("total_due_usd") or 0.0) for item in previews), 6), + }, + } + + def update_billable_event_status(self, *, billable_event_id: str, status: str) -> Dict[str, Any]: + normalized = str(status or "").strip() + if normalized not in COMMERCIAL_BILLABLE_STATUSES: + raise ValueError("billable_event_status_invalid:%s" % normalized) + updated = self.repository.update_billable_event_status(billable_event_id, status=normalized) + self.audit.record_audit_log( + actor_id="ops_billing", + actor_role="reviewer", + account_id=updated.get("account_id"), + object_type="billable_event", + object_id=billable_event_id, + action_type="billable_event_status_changed", + source_surface="ops", + customer_visible_payload={"billable_event": updated}, + internal_payload={"status": normalized}, + ) + return updated + + def trace_billing_projection(self, *, trace_id: str) -> Dict[str, Any]: + events = self.repository.list_billable_events(trace_id=trace_id, limit=20) + related_invoice_ids = {str(item.get("usage_ledger_id") or "") for item in events if str(item.get("usage_ledger_id") or "").strip()} + previews = [] + for invoice in self.repository.list_invoice_previews(limit=100): + if str(invoice.get("usage_ledger_id") or "") in related_invoice_ids: + previews.append(invoice) + return { + "billable_events": events, + "invoice_previews": previews[:5], + "summary": { + "billable_event_count": len(events), + "total_amount_usd": round(sum(float(item.get("amount_usd") or 0.0) for item in events), 6), + "status_counts": dict(Counter(str(item.get("status") or "unknown") for item in events)), + }, + } diff --git a/src/narrativeos/services/commercial_delivery_bundle.py b/src/narrativeos/services/commercial_delivery_bundle.py new file mode 100644 index 0000000..0462ed5 --- /dev/null +++ b/src/narrativeos/services/commercial_delivery_bundle.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import csv +import hashlib +import json +import shutil +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List + + +ROOT = Path(__file__).resolve().parents[3] + +DEFAULT_BUNDLE_SOURCES = ( + {"source": "docs/commercial_customer_delivery_pack.md", "target": "docs/commercial_customer_delivery_pack.md", "required": True}, + {"source": "docs/commercial_customer_acceptance_checklist.md", "target": "docs/commercial_customer_acceptance_checklist.md", "required": True}, + {"source": "docs/stripe_sandbox_external_acceptance_record.md", "target": "docs/stripe_sandbox_external_acceptance_record.md", "required": True}, + {"source": "artifacts/commercialization_uat/latest/summary.json", "target": "evidence/commercialization_uat_summary.json", "required": True}, + {"source": "artifacts/commercialization_uat/latest/report.md", "target": "evidence/commercialization_uat_report.md", "required": True}, + {"source": "artifacts/commercialization_uat/latest/customer_signoff_packet.md", "target": "evidence/customer_signoff_packet.md", "required": True}, + {"source": "artifacts/stripe_external_acceptance/latest/external_acceptance_summary.json", "target": "evidence/stripe_external_acceptance_summary.json", "required": True}, + {"source": "artifacts/stripe_external_acceptance/latest/external_acceptance_record.md", "target": "evidence/stripe_external_acceptance_record.md", "required": True}, + {"source": "artifacts/stripe_external_acceptance/latest/reader_checkout_verification.json", "target": "evidence/reader_checkout_verification.json", "required": False}, +) + + +def _utcnow() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _run_id() -> str: + return f"commercial_delivery_bundle_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}" + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _load_json(path: Path) -> Dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _copy_sources(bundle_dir: Path) -> List[Dict[str, Any]]: + copied: List[Dict[str, Any]] = [] + for item in DEFAULT_BUNDLE_SOURCES: + source_rel = str(item["source"]) + target_rel = str(item["target"]) + source = ROOT / source_rel + if not source.exists(): + if bool(item.get("required", True)): + raise FileNotFoundError(f"missing_bundle_source:{source_rel}") + continue + target = bundle_dir / target_rel + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + copied.append( + { + "source": source_rel, + "target": target_rel, + "size_bytes": target.stat().st_size, + "sha256": _sha256(target), + } + ) + return copied + + +def _build_signoff_summary(bundle_dir: Path, *, uat_summary: Dict[str, Any], stripe_summary: Dict[str, Any]) -> Path: + acceptance = stripe_summary.get("acceptance") or {} + lines = [ + "# Customer Signoff Summary", + "", + f"- generated_at: {_utcnow()}", + f"- bundle_status: {'ready_for_signature' if acceptance.get('all_passed') else 'blocked'}", + "", + "## Included Evidence", + "- commercial customer delivery pack", + "- customer acceptance checklist", + "- local commercialization UAT evidence", + "- Stripe sandbox external acceptance evidence", + "", + "## Key Commercial Outcomes", + f"- canonical invoice preview due: {(uat_summary.get('journey') or {}).get('invoice_preview', {}).get('total_due_usd')}", + f"- external provider invoice due: {(stripe_summary.get('invoice') or {}).get('invoice_payload_json', {}).get('amount_due')}", + f"- external provider invoice line_count: {(stripe_summary.get('invoice') or {}).get('invoice_payload_json', {}).get('lines', {}).get('total_count')}", + f"- invoice final status: {(stripe_summary.get('invoice') or {}).get('status')}", + f"- dunning final status: {(stripe_summary.get('dunning_summary') or {}).get('status')}", + f"- renewal status: {(stripe_summary.get('renewal_summary') or {}).get('status')}", + f"- upgrade recommendation: {(stripe_summary.get('expansion_summary') or {}).get('recommended_plan_id')}", + "", + "## Acceptance Checks", + ] + for key, value in acceptance.items(): + lines.append(f"- {key}: {value}") + path = bundle_dir / "customer_signoff_summary.md" + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def _build_evidence_index(bundle_dir: Path, copied_files: List[Dict[str, Any]]) -> Path: + path = bundle_dir / "evidence_index.csv" + with path.open("w", encoding="utf-8", newline="") as fh: + writer = csv.DictWriter(fh, fieldnames=["source", "target", "size_bytes", "sha256"]) + writer.writeheader() + for item in copied_files: + writer.writerow(item) + return path + + +def _build_manifest(bundle_dir: Path, *, copied_files: List[Dict[str, Any]]) -> Path: + uat_summary = _load_json(bundle_dir / "evidence/commercialization_uat_summary.json") + stripe_summary = _load_json(bundle_dir / "evidence/stripe_external_acceptance_summary.json") + manifest = { + "bundle_id": bundle_dir.name, + "generated_at": _utcnow(), + "bundle_status": "ready_for_signature" if (stripe_summary.get("acceptance") or {}).get("all_passed") else "blocked", + "included_files": copied_files, + "evidence_summary": { + "commercialization_uat_passed": bool((uat_summary.get("acceptance") or {}).get("all_passed")), + "stripe_external_acceptance_passed": bool((stripe_summary.get("acceptance") or {}).get("all_passed")), + "invoice_status": (stripe_summary.get("invoice") or {}).get("status"), + "renewal_status": (stripe_summary.get("renewal_summary") or {}).get("status"), + "dunning_status": (stripe_summary.get("dunning_summary") or {}).get("status"), + "upgrade_recommendation": (stripe_summary.get("expansion_summary") or {}).get("recommended_plan_id"), + }, + } + path = bundle_dir / "bundle_manifest.json" + path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + return path + + +def _build_readme(bundle_dir: Path) -> Path: + text = "\n".join( + [ + "# Commercial Delivery Bundle", + "", + "This bundle is the final customer-facing signature / delivery / acceptance pack.", + "", + "## Start Here", + "- `customer_signoff_summary.md`", + "- `docs/commercial_customer_delivery_pack.md`", + "- `docs/commercial_customer_acceptance_checklist.md`", + "", + "## Evidence", + "- `evidence/commercialization_uat_summary.json`", + "- `evidence/stripe_external_acceptance_summary.json`", + "- `evidence/customer_signoff_packet.md`", + "", + "## Notes", + "- customer-safe only", + "- no webhook secrets included", + "- no local sqlite databases included", + ] + ) + path = bundle_dir / "README.md" + path.write_text(text + "\n", encoding="utf-8") + return path + + +def _zip_bundle(bundle_dir: Path) -> Path: + zip_path = bundle_dir.parent / f"{bundle_dir.name}.zip" + with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for path in sorted(bundle_dir.rglob("*")): + if path.is_file(): + zf.write(path, path.relative_to(bundle_dir)) + return zip_path + + +def build_commercial_delivery_bundle(output_root: str | Path | None = None) -> Dict[str, Any]: + run_id = _run_id() + bundle_dir = Path(output_root) if output_root else (ROOT / "artifacts" / "commercial_delivery_bundle" / run_id) + bundle_dir.mkdir(parents=True, exist_ok=True) + copied_files = _copy_sources(bundle_dir) + uat_summary = _load_json(bundle_dir / "evidence/commercialization_uat_summary.json") + stripe_summary = _load_json(bundle_dir / "evidence/stripe_external_acceptance_summary.json") + signoff_summary = _build_signoff_summary(bundle_dir, uat_summary=uat_summary, stripe_summary=stripe_summary) + evidence_index = _build_evidence_index(bundle_dir, copied_files) + manifest = _build_manifest(bundle_dir, copied_files=copied_files) + readme = _build_readme(bundle_dir) + zip_path = _zip_bundle(bundle_dir) + + latest_dir = bundle_dir.parent / "latest" + if latest_dir.exists(): + shutil.rmtree(latest_dir) + shutil.copytree(bundle_dir, latest_dir) + + return { + "bundle_id": bundle_dir.name, + "bundle_dir": str(bundle_dir), + "latest_dir": str(latest_dir), + "zip_path": str(zip_path), + "readme": str(readme.relative_to(bundle_dir)), + "manifest": str(manifest.relative_to(bundle_dir)), + "signoff_summary": str(signoff_summary.relative_to(bundle_dir)), + "evidence_index": str(evidence_index.relative_to(bundle_dir)), + "acceptance_passed": bool((stripe_summary.get("acceptance") or {}).get("all_passed")), + "included_file_count": len(copied_files) + 4, + } diff --git a/src/narrativeos/services/commercial_lifecycle_automation.py b/src/narrativeos/services/commercial_lifecycle_automation.py new file mode 100644 index 0000000..700c5ef --- /dev/null +++ b/src/narrativeos/services/commercial_lifecycle_automation.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional + +from .commercial_audit import CommercialAuditService +from .commercial_billing import CommercialBillingService +from .commercial_support import CommercialSupportService +from .customer_accounts import CustomerAccountService +from .customer_campaigns import CustomerCampaignService +from ..persistence.repositories import SQLAlchemyPlatformRepository + + +class CommercialLifecycleAutomationService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + customer_account_service: CustomerAccountService, + customer_campaign_service: CustomerCampaignService, + commercial_billing_service: CommercialBillingService, + commercial_support_service: CommercialSupportService, + audit_service: CommercialAuditService, + ) -> None: + self.repository = repository + self.customer_accounts = customer_account_service + self.customer_campaigns = customer_campaign_service + self.commercial_billing = commercial_billing_service + self.commercial_support = commercial_support_service + self.audit = audit_service + + def _utcnow(self) -> datetime: + return datetime.now(timezone.utc) + + def _parse_dt(self, value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + def _next_upgrade_plan_id(self, plan_id: Optional[str]) -> Optional[str]: + normalized = str(plan_id or "").strip() + if normalized == "play_pass": + return "creator_pass" + if normalized == "creator_pass": + return "studio_pass" + return None + + def sync_account(self, *, account_id: str) -> Dict[str, Any]: + account_detail = self.customer_accounts.customer_account_detail(account_id=account_id) + customer = dict(account_detail.get("customer_account") or {}) + plan = dict(account_detail.get("plan") or {}) + invoice_preview = self.commercial_billing.invoice_preview(account_id=account_id) + invoices = self.repository.list_invoice_issuances(account_id=account_id, limit=100) + payment_transactions = self.repository.list_payment_transactions(account_id=account_id, limit=200) + payment_retry_attempts = self.repository.list_payment_retry_attempts(account_id=account_id, limit=200) + campaigns = self.customer_campaigns.list_campaigns(account_id=account_id, limit=100).get("campaigns", []) + active_overages = [item for item in self.repository.list_overage_flags(account_id=account_id, limit=100) if str(item.get("status") or "") == "active"] + support_cases = self.repository.list_support_cases(account_id=account_id, limit=100) + disputes = self.repository.list_disputes(account_id=account_id, limit=100) + billable_events = self.repository.list_billable_events(account_id=account_id, limit=500) + now = self._utcnow() + customer_status = str(customer.get("status") or "trial") + metadata_json = dict(customer.get("metadata_json") or {}) + + renewal_due_at = self._parse_dt(customer.get("renewal_due_at")) + renewal_status = "stable" + if customer_status == "renewal_due" or (renewal_due_at and renewal_due_at <= now + timedelta(days=14)): + renewal_status = "renewal_due" + if renewal_status == "renewal_due" and customer_status in {"active", "renewal_due"}: + customer_status = "renewal_due" + elif renewal_status == "stable" and customer_status == "renewal_due": + customer_status = "active" + renewal_tracker = self.repository.save_renewal_tracker( + { + "renewal_tracker_id": f"renewal_{account_id}", + "customer_account_id": customer["customer_account_id"], + "account_id": account_id, + "status": renewal_status, + "renewal_due_at": customer.get("renewal_due_at"), + "tracker_payload": { + "customer_status": customer_status, + "plan_id": plan.get("plan_id"), + "renewal_window_days": 14, + }, + } + ) + + latest_invoice = invoices[0] if invoices else None + dunning_run = None + open_invoice_statuses = {"issued", "failed"} + failed_invoice_count = sum(1 for item in invoices if str(item.get("status") or "") == "failed") + failed_payment_count = sum(1 for item in payment_transactions if str(item.get("status") or "") == "failed") + retry_count = len([item for item in payment_retry_attempts if str(item.get("invoice_id") or "") == str((latest_invoice or {}).get("invoice_id") or "")]) + dunning_status = "clear" + if latest_invoice and str(latest_invoice.get("status") or "") in open_invoice_statuses and float(latest_invoice.get("total_due_usd") or 0.0) > 0.0: + current_step = "payment_failed_followup" if str(latest_invoice.get("status") or "") == "failed" else "invoice_open_notice" + if retry_count > 0: + current_step = "retry_scheduled" + dunning_run = self.repository.save_dunning_run( + { + "dunning_run_id": f"dunning_run_{latest_invoice['invoice_id']}", + "customer_account_id": customer["customer_account_id"], + "account_id": account_id, + "invoice_id": latest_invoice["invoice_id"], + "status": "open", + "current_step": current_step, + "dunning_payload": { + "invoice_status": latest_invoice.get("status"), + "total_due_usd": latest_invoice.get("total_due_usd"), + "retry_count": retry_count, + "hosted_invoice_url": latest_invoice.get("hosted_invoice_url"), + "invoice_pdf_url": latest_invoice.get("invoice_pdf_url"), + }, + } + ) + dunning_status = "open" + elif latest_invoice and str(latest_invoice.get("status") or "") == "paid": + dunning_run = self.repository.save_dunning_run( + { + "dunning_run_id": f"dunning_run_{latest_invoice['invoice_id']}", + "customer_account_id": customer["customer_account_id"], + "account_id": account_id, + "invoice_id": latest_invoice["invoice_id"], + "status": "resolved", + "current_step": "paid", + "dunning_payload": { + "invoice_status": latest_invoice.get("status"), + "total_due_usd": latest_invoice.get("total_due_usd"), + }, + } + ) + dunning_status = "resolved" + + active_campaigns = [item for item in campaigns if str((item.get("campaign") or {}).get("activation_status") or "") == "active"] + validated_billable_count = len([item for item in billable_events if str(item.get("status") or "") in {"recorded", "approved"}]) + pilot_status = "watch" + if customer_status == "trial" and active_campaigns and validated_billable_count >= 3: + pilot_status = "ready_for_conversion" + elif customer_status in {"active", "renewal_due"} and validated_billable_count > 0: + pilot_status = "converted" + pilot_track = self.repository.save_pilot_conversion_track( + { + "pilot_conversion_track_id": f"pilot_conversion_{account_id}", + "customer_account_id": customer["customer_account_id"], + "account_id": account_id, + "status": pilot_status, + "track_payload": { + "active_campaign_count": len(active_campaigns), + "billable_event_count": len(billable_events), + "validated_billable_count": validated_billable_count, + }, + } + ) + + expansion_candidate = None + next_upgrade_plan_id = self._next_upgrade_plan_id(plan.get("plan_id")) + active_overage_units = sum(float(item.get("overage_units") or 0.0) for item in active_overages) + if active_overages and next_upgrade_plan_id: + expansion_candidate = self.repository.save_expansion_candidate( + { + "expansion_candidate_id": f"expansion_{account_id}", + "customer_account_id": customer["customer_account_id"], + "account_id": account_id, + "status": "recommended", + "trigger_type": "overage_to_upgrade", + "candidate_payload": { + "active_overage_flag_count": len(active_overages), + "active_overage_units": active_overage_units, + "invoice_due_usd": (invoice_preview.get("invoice_preview") or {}).get("total_due_usd") or 0.0, + "current_plan_id": plan.get("plan_id"), + "recommended_plan_id": next_upgrade_plan_id, + }, + } + ) + + risk_level = "low" + no_recent_paid_invoice = customer_status in {"active", "paused", "renewal_due"} and not any( + str(item.get("status") or "") == "paid" for item in invoices + ) + open_dispute_count = sum(1 for item in disputes if str(item.get("status") or "") in {"open", "under_review", "approved"}) + open_support_count = sum(1 for item in support_cases if str(item.get("status") or "") in {"open", "in_progress"}) + if customer_status in {"paused", "renewal_due"} or open_dispute_count > 0 or failed_invoice_count >= 2 or failed_payment_count >= 2: + risk_level = "high" + elif failed_invoice_count > 0 or failed_payment_count > 0 or open_support_count > 0 or no_recent_paid_invoice: + risk_level = "medium" + churn_flag = self.repository.save_churn_risk_flag( + { + "churn_risk_flag_id": f"churn_{account_id}", + "customer_account_id": customer["customer_account_id"], + "account_id": account_id, + "status": "watch" if risk_level != "low" else "stable", + "risk_level": risk_level, + "flag_payload": { + "open_disputes": open_dispute_count, + "open_support_cases": open_support_count, + "latest_invoice_status": (latest_invoice or {}).get("status"), + "failed_invoice_count": failed_invoice_count, + "failed_payment_count": failed_payment_count, + "no_recent_paid_invoice": no_recent_paid_invoice, + }, + } + ) + + metadata_json["renewal_tracker_status"] = renewal_tracker.get("status") + metadata_json["dunning_status"] = dunning_status + metadata_json["pilot_conversion_status"] = pilot_track.get("status") + metadata_json["expansion_status"] = expansion_candidate.get("status") if expansion_candidate else "clear" + metadata_json["upgrade_recommendation_plan_id"] = ( + ((expansion_candidate or {}).get("candidate_payload_json") or {}).get("recommended_plan_id") + if expansion_candidate + else None + ) + metadata_json["churn_risk_status"] = churn_flag.get("status") + metadata_json["churn_risk_level"] = churn_flag.get("risk_level") + metadata_json["dunning_invoice_id"] = (dunning_run or {}).get("invoice_id") + self.repository.save_customer_account({**customer, "status": customer_status, "metadata_json": metadata_json}) + + result = { + "renewal_tracker": renewal_tracker, + "dunning_run": dunning_run, + "pilot_conversion_track": pilot_track, + "expansion_candidate": expansion_candidate, + "churn_risk_flag": churn_flag, + "summary": { + "renewal_status": renewal_tracker.get("status"), + "dunning_status": dunning_status, + "pilot_conversion_status": pilot_track.get("status"), + "expansion_status": expansion_candidate.get("status") if expansion_candidate else "clear", + "churn_risk_level": churn_flag.get("risk_level"), + "churn_risk_status": churn_flag.get("status"), + "recommended_plan_id": next_upgrade_plan_id if expansion_candidate else None, + "open_dispute_count": open_dispute_count, + "open_support_case_count": open_support_count, + }, + } + self.audit.record_audit_log( + actor_id="automation_sync", + actor_role="ops", + account_id=account_id, + object_type="commercial_lifecycle", + object_id=account_id, + action_type="lifecycle_automation_synced", + source_surface="ops", + customer_visible_payload={"summary": result["summary"], "renewal_tracker": renewal_tracker, "dunning_run": dunning_run}, + internal_payload={ + "invoice_count": len(invoices), + "campaign_count": len(campaigns), + "billable_event_count": len(billable_events), + }, + ) + return result + + def list_account_state(self, *, account_id: Optional[str] = None, limit: int = 100) -> Dict[str, Any]: + return { + "renewal_trackers": self.repository.list_renewal_trackers(account_id=account_id, limit=limit), + "dunning_runs": self.repository.list_dunning_runs(account_id=account_id, limit=limit), + "pilot_conversion_tracks": self.repository.list_pilot_conversion_tracks(account_id=account_id, limit=limit), + "expansion_candidates": self.repository.list_expansion_candidates(account_id=account_id, limit=limit), + "churn_risk_flags": self.repository.list_churn_risk_flags(account_id=account_id, limit=limit), + } diff --git a/src/narrativeos/services/commercial_support.py b/src/narrativeos/services/commercial_support.py new file mode 100644 index 0000000..b00fde2 --- /dev/null +++ b/src/narrativeos/services/commercial_support.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +from collections import Counter +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from .commercial_billing import CommercialBillingService +from .commercial_audit import CommercialAuditService +from .customer_accounts import CustomerAccountService +from ..persistence.repositories import SQLAlchemyPlatformRepository + + +DISPUTE_STATUSES = {"open", "under_review", "approved", "rejected", "credited", "reversed", "refunded", "resolved"} +REFUND_STATUSES = {"requested", "approved", "rejected", "paid"} +SUPPORT_CASE_STATUSES = {"open", "in_progress", "resolved", "dismissed"} +SETTLEMENT_RUN_STATUSES = {"draft", "finalized"} +MANUAL_ADJUSTMENT_TYPES = {"credit", "reversal", "refund", "writeoff"} +BILLABLE_EVENT_MUTATION_STATUSES = {"recorded", "approved", "disputed", "credited", "reversed", "refunded"} + + +class CommercialSupportService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + customer_account_service: CustomerAccountService, + commercial_billing_service: CommercialBillingService, + audit_service: CommercialAuditService, + ) -> None: + self.repository = repository + self.customer_accounts = customer_account_service + self.commercial_billing = commercial_billing_service + self.audit = audit_service + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _validate_status(self, value: str, allowed: set[str], *, name: str) -> str: + normalized = str(value or "").strip() + if normalized not in allowed: + raise ValueError(f"{name}_invalid:{normalized}") + return normalized + + def _customer_context(self, *, account_id: str) -> Dict[str, Any]: + return self.customer_accounts.customer_account_detail(account_id=account_id) + + def create_dispute( + self, + *, + account_id: str, + requested_by: str, + payload: Dict[str, Any], + ) -> Dict[str, Any]: + customer = self._customer_context(account_id=account_id)["customer_account"] + billable_event_id = payload.get("billable_event_id") + billable_event = self.repository.get_billable_event(billable_event_id) if billable_event_id else None + invoice_preview_id = payload.get("invoice_preview_id") or (billable_event or {}).get("event_payload_json", {}).get("invoice_preview_id") + dispute = self.repository.save_dispute( + { + "customer_account_id": customer["customer_account_id"], + "account_id": account_id, + "campaign_id": payload.get("campaign_id"), + "invoice_preview_id": invoice_preview_id, + "billable_event_id": billable_event_id, + "quality_event_id": payload.get("quality_event_id") or (billable_event or {}).get("quality_event_id"), + "trace_id": payload.get("trace_id") or (billable_event or {}).get("trace_id"), + "dispute_reason_code": payload["dispute_reason_code"], + "note": payload.get("note"), + "status": "open", + "requested_amount_usd": float(payload.get("requested_amount_usd") or (billable_event or {}).get("amount_usd") or 0.0), + "resolved_amount_usd": 0.0, + "requested_by": requested_by, + "dispute_payload": { + "billable_event": billable_event or {}, + "request_source": "customer_workspace", + }, + } + ) + self.audit.record_audit_log( + actor_id=requested_by, + actor_role="customer", + account_id=account_id, + object_type="dispute", + object_id=dispute["dispute_id"], + action_type="dispute_created", + source_surface="customer", + customer_visible_payload={"dispute": dispute}, + internal_payload={"payload": payload}, + ) + return dispute + + def list_disputes( + self, + *, + account_id: Optional[str] = None, + status: Optional[str] = None, + limit: int = 50, + ) -> Dict[str, Any]: + disputes = self.repository.list_disputes(account_id=account_id, status=status, limit=limit) + return { + "disputes": disputes, + "summary": { + "dispute_count": len(disputes), + "status_counts": dict(Counter(str(item.get("status") or "unknown") for item in disputes)), + }, + } + + def _set_billable_event_status(self, *, billable_event_id: Optional[str], status: str) -> Optional[Dict[str, Any]]: + if not billable_event_id: + return None + normalized = self._validate_status(status, BILLABLE_EVENT_MUTATION_STATUSES, name="billable_event_status") + return self.commercial_billing.update_billable_event_status(billable_event_id=billable_event_id, status=normalized) + + def decide_dispute( + self, + *, + dispute_id: str, + reviewer_id: str, + decision: str, + note: Optional[str] = None, + ) -> Dict[str, Any]: + normalized_decision = str(decision or "").strip() + if normalized_decision not in {"approve", "reject", "credit", "reverse", "refund", "resolve"}: + raise ValueError("dispute_decision_invalid") + dispute = self.repository.get_dispute(dispute_id) + updated_status = { + "approve": "approved", + "reject": "rejected", + "credit": "credited", + "reverse": "reversed", + "refund": "refunded", + "resolve": "resolved", + }[normalized_decision] + billable_event = None + refund_request = None + adjustment = None + if normalized_decision == "approve": + billable_event = self._set_billable_event_status(billable_event_id=dispute.get("billable_event_id"), status="disputed") + elif normalized_decision == "credit": + billable_event = self._set_billable_event_status(billable_event_id=dispute.get("billable_event_id"), status="credited") + adjustment = self.repository.save_manual_adjustment( + { + "customer_account_id": dispute["customer_account_id"], + "account_id": dispute["account_id"], + "dispute_id": dispute["dispute_id"], + "invoice_preview_id": dispute.get("invoice_preview_id"), + "billable_event_id": dispute.get("billable_event_id"), + "adjustment_type": "credit", + "amount_usd": float(dispute.get("requested_amount_usd") or 0.0), + "requested_by": reviewer_id, + "reviewer_id": reviewer_id, + "adjustment_payload": {"note": note}, + } + ) + elif normalized_decision == "reverse": + billable_event = self._set_billable_event_status(billable_event_id=dispute.get("billable_event_id"), status="reversed") + adjustment = self.repository.save_manual_adjustment( + { + "customer_account_id": dispute["customer_account_id"], + "account_id": dispute["account_id"], + "dispute_id": dispute["dispute_id"], + "invoice_preview_id": dispute.get("invoice_preview_id"), + "billable_event_id": dispute.get("billable_event_id"), + "adjustment_type": "reversal", + "amount_usd": float(dispute.get("requested_amount_usd") or 0.0), + "requested_by": reviewer_id, + "reviewer_id": reviewer_id, + "adjustment_payload": {"note": note}, + } + ) + elif normalized_decision == "refund": + billable_event = self._set_billable_event_status(billable_event_id=dispute.get("billable_event_id"), status="refunded") + refund_request = self.repository.save_refund_request( + { + "dispute_id": dispute["dispute_id"], + "customer_account_id": dispute["customer_account_id"], + "account_id": dispute["account_id"], + "invoice_preview_id": dispute.get("invoice_preview_id"), + "billable_event_id": dispute.get("billable_event_id"), + "trace_id": dispute.get("trace_id"), + "status": "approved", + "requested_amount_usd": float(dispute.get("requested_amount_usd") or 0.0), + "approved_amount_usd": float(dispute.get("requested_amount_usd") or 0.0), + "requested_by": dispute.get("requested_by") or reviewer_id, + "reviewer_id": reviewer_id, + "refund_payload": {"note": note}, + } + ) + saved = self.repository.save_dispute( + { + **dispute, + "status": self._validate_status(updated_status, DISPUTE_STATUSES, name="dispute_status"), + "resolved_amount_usd": float(dispute.get("requested_amount_usd") or 0.0), + "reviewer_id": reviewer_id, + "resolution_note": note, + "dispute_payload_json": dispute.get("dispute_payload_json", {}), + } + ) + result = { + "dispute": saved, + "billable_event": billable_event, + "refund_request": refund_request, + "manual_adjustment": adjustment, + } + self.audit.record_audit_log( + actor_id=reviewer_id, + actor_role="reviewer", + account_id=dispute.get("account_id"), + object_type="dispute", + object_id=dispute_id, + action_type="dispute_decided", + source_surface="ops", + customer_visible_payload={"dispute": saved}, + internal_payload={**result, "decision": normalized_decision, "note": note}, + ) + return result + + def create_support_case( + self, + *, + account_id: str, + requested_by: str, + payload: Dict[str, Any], + ) -> Dict[str, Any]: + customer = self._customer_context(account_id=account_id)["customer_account"] + support_case = self.repository.save_support_case( + { + "customer_account_id": customer["customer_account_id"], + "account_id": 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": "open", + "priority": payload.get("priority", "medium"), + "requested_by": requested_by, + "support_payload": {"request_source": "customer_workspace"}, + } + ) + self.audit.record_audit_log( + actor_id=requested_by, + actor_role="customer", + account_id=account_id, + object_type="support_case", + object_id=support_case["support_case_id"], + action_type="support_case_created", + source_surface="customer", + customer_visible_payload={"support_case": support_case}, + internal_payload={"payload": payload}, + ) + return support_case + + def list_support_cases( + self, + *, + account_id: Optional[str] = None, + status: Optional[str] = None, + limit: int = 50, + ) -> Dict[str, Any]: + cases = self.repository.list_support_cases(account_id=account_id, status=status, limit=limit) + return { + "support_cases": cases, + "summary": { + "case_count": len(cases), + "status_counts": dict(Counter(str(item.get("status") or "unknown") for item in cases)), + }, + } + + def update_support_case_status( + self, + *, + support_case_id: str, + reviewer_id: str, + status: str, + note: Optional[str] = None, + ) -> Dict[str, Any]: + case = self.repository.get_support_case(support_case_id) + saved = self.repository.save_support_case( + { + **case, + "status": self._validate_status(status, SUPPORT_CASE_STATUSES, name="support_case_status"), + "owner_id": reviewer_id if status in {"in_progress", "resolved"} else case.get("owner_id"), + "resolution_note": note, + "support_payload_json": case.get("support_payload_json", {}), + } + ) + self.audit.record_audit_log( + actor_id=reviewer_id, + actor_role="reviewer", + account_id=case.get("account_id"), + object_type="support_case", + object_id=support_case_id, + action_type="support_case_status_changed", + source_surface="ops", + customer_visible_payload={"support_case": saved}, + internal_payload={"status": status, "note": note}, + ) + return saved + + def create_manual_adjustment( + self, + *, + account_id: str, + reviewer_id: str, + payload: Dict[str, Any], + ) -> Dict[str, Any]: + customer = self._customer_context(account_id=account_id)["customer_account"] + adjustment = self.repository.save_manual_adjustment( + { + "customer_account_id": customer["customer_account_id"], + "account_id": 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), + "requested_by": reviewer_id, + "reviewer_id": reviewer_id, + "adjustment_payload": dict(payload.get("adjustment_payload") or {}), + } + ) + if payload.get("billable_event_id") and payload.get("target_billable_status"): + self._set_billable_event_status( + billable_event_id=payload.get("billable_event_id"), + status=payload.get("target_billable_status"), + ) + self.audit.record_audit_log( + actor_id=reviewer_id, + actor_role="reviewer", + account_id=account_id, + object_type="manual_adjustment", + object_id=adjustment["adjustment_id"], + action_type="manual_adjustment_created", + source_surface="ops", + customer_visible_payload={"manual_adjustment": adjustment}, + internal_payload={"payload": payload}, + ) + return adjustment + + def generate_settlement_run( + self, + *, + account_id: str, + reviewer_id: str, + ) -> Dict[str, Any]: + invoice_preview = self.commercial_billing.invoice_preview(account_id=account_id) + customer = self._customer_context(account_id=account_id)["customer_account"] + run = self.repository.save_settlement_run( + { + "customer_account_id": customer["customer_account_id"], + "account_id": account_id, + "billing_period_start": (invoice_preview.get("usage_ledger") or {}).get("billing_period_start"), + "billing_period_end": (invoice_preview.get("usage_ledger") or {}).get("billing_period_end"), + "status": "finalized", + "subtotal_amount_usd": (invoice_preview.get("invoice_preview") or {}).get("subtotal_amount_usd") or 0.0, + "disputed_amount_usd": (invoice_preview.get("invoice_preview") or {}).get("disputed_amount_usd") or 0.0, + "credited_amount_usd": (invoice_preview.get("invoice_preview") or {}).get("credited_amount_usd") or 0.0, + "reversed_amount_usd": (invoice_preview.get("invoice_preview") or {}).get("reversed_amount_usd") or 0.0, + "refunded_amount_usd": 0.0, + "net_amount_usd": (invoice_preview.get("invoice_preview") or {}).get("total_due_usd") or 0.0, + "run_payload": {"generated_by": reviewer_id}, + } + ) + items: List[Dict[str, Any]] = [] + for event in self.repository.list_billable_events(account_id=account_id, limit=500): + status = str(event.get("status") or "recorded") + if status == "recorded": + event = self.commercial_billing.update_billable_event_status( + billable_event_id=event["billable_event_id"], + status="approved", + ) + status = "approved" + items.append( + self.repository.save_settlement_item( + { + "settlement_run_id": run["settlement_run_id"], + "billable_event_id": event.get("billable_event_id"), + "invoice_preview_id": invoice_preview.get("invoice_preview", {}).get("invoice_preview_id"), + "status": status, + "amount_usd": event.get("amount_usd") or 0.0, + "item_payload": {"trace_id": event.get("trace_id"), "billable_metric": event.get("billable_metric")}, + } + ) + ) + return { + "settlement_run": run, + "settlement_items": items, + } diff --git a/src/narrativeos/services/commercialization_uat.py b/src/narrativeos/services/commercialization_uat.py new file mode 100644 index 0000000..a3ae577 --- /dev/null +++ b/src/narrativeos/services/commercialization_uat.py @@ -0,0 +1,545 @@ +from __future__ import annotations + +import json +import os +import shutil +import sys +import types +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict + +from fastapi.testclient import TestClient + +from ..repository import SQLAlchemyRepository + +_STRIPE_ENV_KEYS = ( + "NARRATIVEOS_BILLING_PROVIDER", + "NARRATIVEOS_STRIPE_SECRET_KEY", + "NARRATIVEOS_STRIPE_PUBLISHABLE_KEY", + "NARRATIVEOS_STRIPE_WEBHOOK_SECRET", + "NARRATIVEOS_STRIPE_PRICE_MAP_JSON", +) + + +def _utcnow() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _run_id() -> str: + return f"commercialization_uat_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}" + + +def _register_identity(client: TestClient, *, actor_id: str, actor_role: str) -> str: + client.post( + "/v1/auth/register", + json={ + "actor_id": actor_id, + "actor_role": actor_role, + "password": "secret123", + "account_id": actor_id, + "display_name": actor_id, + }, + ) + login = client.post("/v1/auth/login", json={"actor_id": actor_id, "password": "secret123"}) + if login.status_code != 200: + raise RuntimeError(f"identity_login_failed:{actor_id}:{login.status_code}") + return login.json()["token"]["access_token"] + + +def _install_fake_stripe() -> Dict[str, Any]: + snapshot = { + "stripe_module_present": "stripe" in sys.modules, + "stripe_module": sys.modules.get("stripe"), + "env": {key: os.environ.get(key) for key in _STRIPE_ENV_KEYS}, + } + + class FakeCustomer: + @staticmethod + def create(**kwargs): + return {"id": "cus_uat_123", **kwargs} + + @staticmethod + def retrieve(customer_id): + return {"id": customer_id} + + class FakeInvoiceItem: + @staticmethod + def create(**kwargs): + return {"id": "ii_uat_123", **kwargs} + + class FakeInvoice: + @staticmethod + def create(**kwargs): + return {"id": "in_uat_123", "status": "draft", **kwargs} + + @staticmethod + def finalize_invoice(invoice_id): + return { + "id": invoice_id, + "status": "open", + "hosted_invoice_url": f"https://invoice.stripe.test/{invoice_id}", + "invoice_pdf": f"https://invoice.stripe.test/{invoice_id}.pdf", + "payment_intent": "pi_uat_123", + } + + @staticmethod + def retrieve(invoice_id): + return { + "id": invoice_id, + "status": "open", + "hosted_invoice_url": f"https://invoice.stripe.test/{invoice_id}", + "invoice_pdf": f"https://invoice.stripe.test/{invoice_id}.pdf", + } + + class FakeCreditNote: + @staticmethod + def create(**kwargs): + return {"id": "cn_uat_123", **kwargs} + + class FakeWebhook: + current_event: Dict[str, Any] = {} + + @staticmethod + def construct_event(payload, sig_header, secret): + return FakeWebhook.current_event + + fake_stripe = types.SimpleNamespace( + Customer=FakeCustomer, + InvoiceItem=FakeInvoiceItem, + Invoice=FakeInvoice, + CreditNote=FakeCreditNote, + Webhook=FakeWebhook, + api_key=None, + api_version=None, + ) + sys.modules["stripe"] = fake_stripe + os.environ["NARRATIVEOS_BILLING_PROVIDER"] = "stripe" + os.environ["NARRATIVEOS_STRIPE_SECRET_KEY"] = "sk_test_uat" + os.environ["NARRATIVEOS_STRIPE_PUBLISHABLE_KEY"] = "pk_test_uat" + os.environ["NARRATIVEOS_STRIPE_WEBHOOK_SECRET"] = "whsec_uat" + os.environ["NARRATIVEOS_STRIPE_PRICE_MAP_JSON"] = json.dumps( + {"play_pass": "price_play", "creator_pass": "price_creator", "studio_pass": "price_studio"} + ) + return snapshot + + +def _restore_fake_stripe(snapshot: Dict[str, Any]) -> None: + if snapshot.get("stripe_module_present"): + sys.modules["stripe"] = snapshot.get("stripe_module") + else: + sys.modules.pop("stripe", None) + env_snapshot = dict(snapshot.get("env") or {}) + for key in _STRIPE_ENV_KEYS: + value = env_snapshot.get(key) + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def _seed_quality_and_billing_bundle(app, *, account_id: str) -> Dict[str, Any]: + app.state.customer_account_service.ensure_customer_account( + account_id=account_id, + display_name="Commercial UAT Customer", + plan_id="play_pass", + status="renewal_due", + ) + customer_detail = app.state.customer_account_service.customer_account_detail(account_id=account_id) + customer_account = customer_detail["customer_account"] + app.state.customer_account_service.upsert_billing_profile( + customer_account_id=customer_account["customer_account_id"], + account_id=account_id, + provider="stripe", + invoice_email="billing@uat.test", + legal_name="Commercial UAT Ltd", + billing_country="GB", + tax_status="pending", + ) + app.state.billing_service.grant_subscription( + { + "account_id": account_id, + "tier_id": "play_pass", + "provider": "ops_manual", + "status": "active", + "period_start": "2026-04-01T00:00:00+00:00", + "period_end": "2026-05-01T00:00:00+00:00", + } + ) + app.state.repository.save_quality_event( + { + "event_id": "quality_event_uat_presented", + "trace_id": "trace_uat_presented", + "event_type": "chapter_quality_evaluated", + "source_surface": "reader", + "status": "passed", + "world_version_id": "urban_mystery_lotus_lane@0.1.0", + "session_id": "session_uat_presented", + "source_ref": {"kind": "chapter", "account_id": account_id}, + "payload": {"reason_codes": ["supported"]}, + } + ) + app.state.observability_service.record_runtime_receipt( + surface="reader", + action="continue_story", + response_status="ok", + world_id="urban_mystery_lotus_lane", + world_version_id="urban_mystery_lotus_lane@0.1.0", + session_id="session_uat_presented", + account_id=account_id, + reader_id=account_id, + candidate_batch={"debug": {}}, + rendered_scene={"debug": {}}, + reader_view={"body": "一段商业化 UAT 正文。"}, + estimated_cost=0.02, + runtime_latency_ms=12.0, + trace_id="trace_uat_presented", + quality_event_id="quality_event_uat_presented", + ) + app.state.repository.save_quality_event( + { + "event_id": "quality_event_uat_handoff", + "trace_id": "trace_uat_handoff", + "event_type": "publish_preflight", + "source_surface": "publish", + "status": "passed", + "world_version_id": "urban_mystery_lotus_lane@0.1.0", + "session_id": None, + "source_ref": {"kind": "world_version", "account_id": account_id}, + "payload": {"reason_codes": ["publish_ready"]}, + } + ) + app.state.repository.save_quality_feedback_item( + { + "feedback_item_id": "feedback_uat_positive", + "feedback_type": "explicit_user_feedback", + "signal": "explicit_positive", + "source_surface": "reader", + "trace_id": "trace_uat_presented", + "account_id": account_id, + "world_version_id": "urban_mystery_lotus_lane@0.1.0", + "session_id": "session_uat_presented", + "source_ref": {"kind": "session", "account_id": account_id}, + "payload": {"reason_code": "not_useful"}, + } + ) + campaign_bundle = app.state.customer_campaign_service.create_or_update_campaign( + account_id=account_id, + payload={ + "title": "Commercial UAT Campaign", + "target_icp_vertical": "B2B SaaS", + "cta_text": "book a pilot", + "disclosure_text": "Sponsored pilot outreach with attribution.", + "selected_channels": ["email"], + "selected_partner_refs": ["partner_uat"], + "proof_points": ["1 validated handoff", "1 validated conversion"], + "proof_source_urls": ["https://example.test/proof/uat"], + "proof_artifact_refs": ["artifact://proof-uat"], + }, + ) + campaign = dict(campaign_bundle.get("campaign") or {}) + app.state.repository.save_campaign( + { + **campaign, + "activation_status": "active", + "selected_channels_json": campaign.get("selected_channels_json") or ["email"], + "selected_partner_refs_json": campaign.get("selected_partner_refs_json") or ["partner_uat"], + "campaign_payload_json": dict(campaign.get("campaign_payload_json") or {}), + } + ) + app.state.repository.save_overage_flag( + { + "overage_flag_id": "overage_uat_handoff", + "account_id": account_id, + "customer_account_id": customer_account["customer_account_id"], + "plan_id": customer_account["plan_id"], + "metric_type": "validated_handoff", + "status": "active", + "observed_units": 2, + "included_units": 0, + "overage_units": 2, + "flag_payload": {"reason": "demo_upgrade_prompt"}, + } + ) + return {"customer_account": customer_account} + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _summary_markdown(summary: Dict[str, Any]) -> str: + journey = summary["journey"] + acceptance = summary["acceptance"] + return "\n".join( + [ + "# Commercialization UAT", + "", + f"- run_id: {summary['run_id']}", + f"- generated_at: {summary['generated_at']}", + f"- account_id: {summary['account_id']}", + f"- customer_account_id: {summary['customer_account_id']}", + "", + "## Journey", + f"- invoice_preview_total_due_usd: {journey['invoice_preview']['total_due_usd']}", + f"- issued_invoice_status: {journey['issued_invoice']['status']}", + f"- failed_invoice_status: {journey['failed_invoice']['status']}", + f"- dunning_status_after_failure: {journey['lifecycle_after_failure']['dunning_summary']['status']}", + f"- renewal_status: {journey['lifecycle_after_failure']['renewal_summary']['status']}", + f"- upgrade_recommendation: {journey['lifecycle_after_failure']['expansion_summary']['recommended_plan_id']}", + f"- paid_invoice_status: {journey['paid_invoice']['status']}", + f"- dunning_status_after_recovery: {journey['lifecycle_after_recovery']['dunning_summary']['status']}", + "", + "## Acceptance", + f"- all_passed: {acceptance['all_passed']}", + *[f"- {item['label']}: {'pass' if item['passed'] else 'fail'}" for item in acceptance["checkpoints"]], + ] + ) + + +def _delivery_packet_markdown(summary: Dict[str, Any]) -> str: + journey = summary["journey"] + return "\n".join( + [ + "# Commercial Delivery Packet", + "", + "## Scope Included", + "- customer account / billing profile lifecycle", + "- invoice preview -> issued invoice -> provider payment status sync", + "- dispute / refund / support core", + "- audit export / tenant isolation / customer-safe payloads", + "- customer workspace reporting and commercialization Ops dashboard", + "- renewal / dunning / pilot conversion / expansion / churn risk automation", + "", + "## UAT Evidence", + f"- invoice preview due: {journey['invoice_preview']['total_due_usd']}", + f"- issued invoice link: {journey['issued_invoice']['hosted_invoice_url']}", + f"- failed payment status: {journey['failed_invoice']['status']}", + f"- paid recovery status: {journey['paid_invoice']['status']}", + f"- dunning after failure: {journey['lifecycle_after_failure']['dunning_summary']['status']}", + f"- renewal posture: {journey['lifecycle_after_failure']['renewal_summary']['status']}", + f"- upgrade suggestion: {journey['lifecycle_after_failure']['expansion_summary']['recommended_plan_id']}", + "", + "## Customer Acceptance Checklist", + "- can view invoice preview in `/app/customer`", + "- ops can issue a formal invoice from preview", + "- provider webhook can mark invoice failed and then paid", + "- failed payment opens dunning posture", + "- renewal due is visible to customer and ops", + "- overage triggers an upgrade recommendation", + "- support / disputes / audit export remain available", + "", + "## Operational Notes", + "- billing provider: Stripe", + "- currency: USD", + "- customer-safe logs only on customer routes", + "- disputes and manual adjustments remain canonical and auditable", + "", + "## Artifacts", + "- `artifacts/commercialization_uat/latest/summary.json`", + "- `artifacts/commercialization_uat/latest/report.md`", + "- `artifacts/commercialization_uat/latest/customer_signoff_packet.md`", + ] + ) + + +def run_commercialization_uat(output_root: str | Path | None = None) -> Dict[str, Any]: + from ..api import create_app + + fake_stripe_snapshot = _install_fake_stripe() + try: + root = Path(__file__).resolve().parents[3] + run_id = _run_id() + base_dir = Path(output_root) if output_root else (root / "artifacts" / "commercialization_uat" / run_id) + base_dir.mkdir(parents=True, exist_ok=True) + database_url = f"sqlite:///{base_dir / 'commercialization_uat.db'}" + app = create_app(repository=SQLAlchemyRepository(database_url=database_url)) + client = TestClient(app) + + customer_actor_id = "customer_commercial_uat" + reviewer_actor_id = "ops_commercial_uat" + customer_token = _register_identity(client, actor_id=customer_actor_id, actor_role="customer") + reviewer_token = _register_identity(client, actor_id=reviewer_actor_id, actor_role="reviewer") + seeded = _seed_quality_and_billing_bundle(app, account_id=customer_actor_id) + customer_account = seeded["customer_account"] + + preview_response = client.get("/v1/customer/invoice-preview", headers={"Authorization": f"Bearer {customer_token}"}) + if preview_response.status_code != 200: + raise RuntimeError(f"invoice_preview_failed:{preview_response.status_code}") + preview_payload = preview_response.json() + invoice_preview = dict(preview_payload["invoice_preview"]) + + issue_response = client.post( + f"/v1/ops/invoices/{invoice_preview['invoice_preview_id']}/issue", + headers={"Authorization": f"Bearer {reviewer_token}"}, + json={}, + ) + if issue_response.status_code != 200: + raise RuntimeError(f"invoice_issue_failed:{issue_response.status_code}") + issued_invoice = dict(issue_response.json()["invoice"]) + + fake_stripe = sys.modules["stripe"] + fake_stripe.Webhook.current_event = { + "id": "evt_uat_failed", + "type": "invoice.payment_failed", + "data": {"object": {"id": issued_invoice["provider_invoice_ref"], "payment_intent": "pi_failed_uat"}}, + } + failed_response = client.post("/v1/billing/stripe/webhook", content=b"{}", headers={"Stripe-Signature": "sig_uat"}) + if failed_response.status_code != 200: + raise RuntimeError(f"failed_webhook_failed:{failed_response.status_code}") + failed_payload = failed_response.json() + + lifecycle_failure = client.post( + "/v1/ops/lifecycle-automation/sync", + headers={"Authorization": f"Bearer {reviewer_token}"}, + json={"account_id": customer_actor_id}, + ) + if lifecycle_failure.status_code != 200: + raise RuntimeError(f"lifecycle_sync_failed:{lifecycle_failure.status_code}") + + workspace_after_failure = client.get("/v1/customer/workspace", headers={"Authorization": f"Bearer {customer_token}"}) + if workspace_after_failure.status_code != 200: + raise RuntimeError(f"customer_workspace_failed:{workspace_after_failure.status_code}") + workspace_failure_payload = workspace_after_failure.json() + + retry_response = client.post( + f"/v1/ops/invoices/{issued_invoice['invoice_id']}/retry-payment", + headers={"Authorization": f"Bearer {reviewer_token}"}, + json={}, + ) + if retry_response.status_code != 200: + raise RuntimeError(f"invoice_retry_failed:{retry_response.status_code}") + + fake_stripe.Webhook.current_event = { + "id": "evt_uat_paid", + "type": "invoice.paid", + "data": {"object": {"id": issued_invoice["provider_invoice_ref"], "payment_intent": "pi_paid_uat"}}, + } + paid_response = client.post("/v1/billing/stripe/webhook", content=b"{}", headers={"Stripe-Signature": "sig_uat"}) + if paid_response.status_code != 200: + raise RuntimeError(f"paid_webhook_failed:{paid_response.status_code}") + paid_payload = paid_response.json() + + lifecycle_recovery = client.post( + "/v1/ops/lifecycle-automation/sync", + headers={"Authorization": f"Bearer {reviewer_token}"}, + json={"account_id": customer_actor_id}, + ) + if lifecycle_recovery.status_code != 200: + raise RuntimeError(f"lifecycle_sync_recovery_failed:{lifecycle_recovery.status_code}") + + customer_invoice = client.get( + f"/v1/customer/invoices/{issued_invoice['invoice_id']}", + headers={"Authorization": f"Bearer {customer_token}"}, + ) + if customer_invoice.status_code != 200: + raise RuntimeError(f"customer_invoice_detail_failed:{customer_invoice.status_code}") + + workspace_after_recovery = client.get("/v1/customer/workspace", headers={"Authorization": f"Bearer {customer_token}"}) + ops_summary = client.get("/v1/ops/commercialization-summary", headers={"Authorization": f"Bearer {reviewer_token}"}) + if workspace_after_recovery.status_code != 200 or ops_summary.status_code != 200: + raise RuntimeError("commercialization_projection_failed") + + workspace_recovery_payload = workspace_after_recovery.json() + ops_summary_payload = ops_summary.json() + + checkpoints = [ + { + "label": "invoice_preview_available", + "passed": float(invoice_preview.get("total_due_usd") or 0.0) > 0.0, + }, + { + "label": "formal_invoice_issued", + "passed": str(issued_invoice.get("status") or "") == "issued" and bool(issued_invoice.get("hosted_invoice_url")), + }, + { + "label": "payment_failure_recorded", + "passed": str((failed_payload.get("invoice") or {}).get("status") or "") == "failed", + }, + { + "label": "dunning_open_after_failure", + "passed": str((workspace_failure_payload.get("dunning_summary") or {}).get("status") or "") == "open", + }, + { + "label": "renewal_due_visible", + "passed": str((workspace_failure_payload.get("renewal_summary") or {}).get("status") or "") == "renewal_due", + }, + { + "label": "upgrade_recommendation_visible", + "passed": bool((workspace_failure_payload.get("expansion_summary") or {}).get("recommended_plan_id")), + }, + { + "label": "payment_recovery_recorded", + "passed": str((paid_payload.get("invoice") or {}).get("status") or "") == "paid", + }, + { + "label": "customer_invoice_paid_view", + "passed": str((customer_invoice.json().get("invoice") or {}).get("status") or "") == "paid", + }, + { + "label": "ops_summary_contains_lifecycle_counts", + "passed": all( + key in ops_summary_payload + for key in ("renewal_due_accounts", "dunning_runs", "pilot_conversion", "expansion_candidates", "churn_risk_accounts") + ), + }, + ] + + summary = { + "run_id": run_id, + "generated_at": _utcnow(), + "environment": "mock_stripe_local", + "account_id": customer_actor_id, + "customer_account_id": customer_account["customer_account_id"], + "journey": { + "invoice_preview": { + "invoice_preview_id": invoice_preview.get("invoice_preview_id"), + "status_counts": preview_payload.get("summary", {}).get("status_counts", {}), + "total_due_usd": invoice_preview.get("total_due_usd"), + "line_items": invoice_preview.get("line_items_json", []), + }, + "issued_invoice": dict(issue_response.json()["invoice"]), + "failed_invoice": dict(failed_payload.get("invoice") or {}), + "retry_attempt": dict(retry_response.json().get("payment_retry_attempt") or {}), + "paid_invoice": dict(paid_payload.get("invoice") or {}), + "lifecycle_after_failure": { + "renewal_summary": workspace_failure_payload.get("renewal_summary", {}), + "dunning_summary": workspace_failure_payload.get("dunning_summary", {}), + "pilot_conversion_summary": workspace_failure_payload.get("pilot_conversion_summary", {}), + "expansion_summary": workspace_failure_payload.get("expansion_summary", {}), + "churn_risk_summary": workspace_failure_payload.get("churn_risk_summary", {}), + }, + "lifecycle_after_recovery": { + "renewal_summary": workspace_recovery_payload.get("renewal_summary", {}), + "dunning_summary": workspace_recovery_payload.get("dunning_summary", {}), + "pilot_conversion_summary": workspace_recovery_payload.get("pilot_conversion_summary", {}), + "expansion_summary": workspace_recovery_payload.get("expansion_summary", {}), + "churn_risk_summary": workspace_recovery_payload.get("churn_risk_summary", {}), + }, + "ops_summary_excerpt": { + "renewal_due_accounts": ops_summary_payload.get("renewal_due_accounts", {}), + "dunning_runs": ops_summary_payload.get("dunning_runs", {}), + "pilot_conversion": ops_summary_payload.get("pilot_conversion", {}), + "expansion_candidates": ops_summary_payload.get("expansion_candidates", {}), + "churn_risk_accounts": ops_summary_payload.get("churn_risk_accounts", {}), + }, + }, + "acceptance": { + "all_passed": all(item["passed"] for item in checkpoints), + "checkpoint_count": len(checkpoints), + "checkpoints": checkpoints, + }, + } + + _write(base_dir / "summary.json", json.dumps(summary, ensure_ascii=False, indent=2)) + _write(base_dir / "report.md", _summary_markdown(summary)) + _write(base_dir / "customer_signoff_packet.md", _delivery_packet_markdown(summary)) + + latest_dir = base_dir.parent / "latest" + if latest_dir.exists(): + shutil.rmtree(latest_dir) + shutil.copytree(base_dir, latest_dir) + return summary + finally: + _restore_fake_stripe(fake_stripe_snapshot) diff --git a/src/narrativeos/services/customer_accounts.py b/src/narrativeos/services/customer_accounts.py new file mode 100644 index 0000000..38bb97f --- /dev/null +++ b/src/narrativeos/services/customer_accounts.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from ..commercialization.config import load_commercial_plans +from ..commercialization.models import BillingProfile, CommercialPlan, CustomerAccount +from ..persistence.repositories import SQLAlchemyPlatformRepository + + +COMMERCIAL_CUSTOMER_ROLES = frozenset({"customer", "reviewer", "ops", "admin"}) + + +class CustomerAccountService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + billing_service: Optional[Any] = None, + ) -> None: + self.repository = repository + self.billing = billing_service + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _plan_bundle(self) -> Dict[str, Any]: + return load_commercial_plans() + + def sync_configured_plans(self) -> Dict[str, Any]: + bundle = self._plan_bundle() + synced: List[Dict[str, Any]] = [] + for plan in bundle["plans"]: + synced.append( + self.repository.save_plan( + { + **plan.to_dict(), + "plan_payload": plan.to_dict(), + } + ) + ) + return { + "config_version": bundle["config_version"], + "plans": synced, + "plan_map": {item["plan_id"]: item for item in synced}, + } + + def _plan_for_id(self, plan_id: str) -> CommercialPlan: + bundle = self._plan_bundle() + plan = bundle["plan_map"].get(str(plan_id) or "") + if plan is None: + raise KeyError("unknown_commercial_plan:%s" % plan_id) + return plan + + def ensure_customer_account( + self, + *, + account_id: str, + display_name: Optional[str] = None, + plan_id: str = "play_pass", + status: str = "trial", + ) -> Dict[str, Any]: + resolved_account_id = str(account_id or "").strip() + if not resolved_account_id: + raise ValueError("customer_account_id_required") + self.sync_configured_plans() + plan = self._plan_for_id(plan_id) + existing = self.repository.get_customer_account_by_account_id(resolved_account_id, default=None) + if existing is not None: + updates = { + **existing, + "display_name": display_name if display_name is not None else existing.get("display_name"), + "plan_id": existing.get("plan_id") or plan.plan_id, + "seat_limit": int(existing.get("seat_limit") or plan.seat_limit), + "workspace_limit": int(existing.get("workspace_limit") or plan.workspace_limit), + "campaign_limit": int(existing.get("campaign_limit") or plan.campaign_limit), + "metadata_json": dict(existing.get("metadata_json") or existing.get("metadata") or {}), + } + return self.repository.save_customer_account(updates) + customer = CustomerAccount( + customer_account_id="cust_%s" % resolved_account_id.replace("@", "_").replace(".", "_"), + account_id=resolved_account_id, + display_name=display_name, + status=status, + plan_id=plan.plan_id, + seat_limit=plan.seat_limit, + workspace_limit=plan.workspace_limit, + campaign_limit=plan.campaign_limit, + renewal_due_at=(datetime.now(timezone.utc) + timedelta(days=14)).isoformat() if status in {"trial", "renewal_due"} else None, + metadata={"config_version": self._plan_bundle()["config_version"]}, + ) + return self.repository.save_customer_account( + { + **customer.to_dict(), + "metadata_json": customer.metadata, + } + ) + + def upsert_billing_profile( + self, + *, + customer_account_id: str, + account_id: str, + provider: str = "internal_preview", + invoice_email: Optional[str] = None, + legal_name: Optional[str] = None, + billing_country: Optional[str] = None, + tax_status: Optional[str] = None, + provider_customer_ref: Optional[str] = None, + ) -> Dict[str, Any]: + existing = next( + iter(self.repository.list_billing_profiles(customer_account_id=customer_account_id, limit=1)), + None, + ) + profile = BillingProfile( + billing_profile_id=str(existing.get("billing_profile_id")) if existing else "billing_profile_%s" % customer_account_id, + customer_account_id=customer_account_id, + account_id=account_id, + provider=provider, + status="active", + invoice_email=invoice_email, + legal_name=legal_name, + billing_country=billing_country, + tax_status=tax_status, + provider_customer_ref=provider_customer_ref, + profile_payload={}, + ) + return self.repository.save_billing_profile( + { + **profile.to_dict(), + "profile_payload_json": profile.profile_payload, + } + ) + + def list_customer_accounts(self, *, status: Optional[str] = None, limit: int = 50) -> Dict[str, Any]: + self.sync_configured_plans() + customers = self.repository.list_customer_accounts(status=status, limit=limit) + counts: Dict[str, int] = {} + for item in customers: + resolved_status = str(item.get("status") or "unknown") + counts[resolved_status] = counts.get(resolved_status, 0) + 1 + return { + "customers": [self.customer_account_detail(customer_account_id=item["customer_account_id"]) for item in customers], + "summary": { + "total_customers": len(customers), + "status_counts": counts, + }, + } + + def customer_account_detail( + self, + *, + customer_account_id: Optional[str] = None, + account_id: Optional[str] = None, + ) -> Dict[str, Any]: + self.sync_configured_plans() + customer = ( + self.repository.get_customer_account(customer_account_id) + if customer_account_id + else self.repository.get_customer_account_by_account_id(str(account_id or "").strip()) + ) + plan = self.repository.get_plan(customer["plan_id"]) + billing_profile = next( + iter(self.repository.list_billing_profiles(customer_account_id=customer["customer_account_id"], limit=1)), + None, + ) + subscription_snapshot = self.billing.subscription_status(account_id=customer["account_id"]) if self.billing else None + entitlements = self.repository.list_entitlements(account_id=customer["account_id"]) if customer.get("account_id") else [] + limit_posture = { + "seat_limit": int(customer.get("seat_limit") or 0), + "seat_count": int(customer.get("seat_count") or 0), + "workspace_limit": int(customer.get("workspace_limit") or 0), + "workspace_count": int(customer.get("workspace_count") or 0), + "campaign_limit": int(customer.get("campaign_limit") or 0), + "campaign_count": int(customer.get("campaign_count") or 0), + "is_over_limit": any( + int(customer.get(count_key) or 0) > int(customer.get(limit_key) or 0) + for limit_key, count_key in ( + ("seat_limit", "seat_count"), + ("workspace_limit", "workspace_count"), + ("campaign_limit", "campaign_count"), + ) + ), + } + metadata = dict(customer.get("metadata_json") or {}) + renewal_risk = "due_soon" if customer.get("status") == "renewal_due" else "stable" + return { + "customer_account": customer, + "plan": plan, + "billing_profile": billing_profile, + "subscription": dict((subscription_snapshot or {}).get("subscription") or {}), + "wallets": dict((subscription_snapshot or {}).get("wallets") or {}), + "entitlement_count": len(entitlements), + "lifecycle_summary": { + "status": customer.get("status"), + "plan_id": plan.get("plan_id"), + "plan_display_name": plan.get("display_name"), + "renewal_due_at": customer.get("renewal_due_at"), + "renewal_risk": renewal_risk, + "renewal_tracker_status": metadata.get("renewal_tracker_status", "stable"), + "dunning_status": metadata.get("dunning_status", "clear"), + "pilot_conversion_status": metadata.get("pilot_conversion_status", "watch"), + "expansion_status": metadata.get("expansion_status", "clear"), + "upgrade_recommendation_plan_id": metadata.get("upgrade_recommendation_plan_id"), + "churn_risk_status": metadata.get("churn_risk_status", "stable"), + "churn_risk_level": metadata.get("churn_risk_level", "low"), + }, + "limit_posture": limit_posture, + } diff --git a/src/narrativeos/services/customer_campaigns.py b/src/narrativeos/services/customer_campaigns.py new file mode 100644 index 0000000..84f2d3d --- /dev/null +++ b/src/narrativeos/services/customer_campaigns.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +from collections import Counter +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from ..quality.models import ReviewCase +from ..persistence.repositories import SQLAlchemyPlatformRepository +from .commercial_audit import CommercialAuditService +from .customer_accounts import CustomerAccountService + + +CAMPAIGN_ACTIVATION_STATUSES = {"draft", "in_review", "approved", "active", "paused", "blocked"} +CAMPAIGN_REVIEW_STATUSES = {"submitted", "in_review", "approved", "needs_changes", "blocked", "activated", "paused"} + + +class CustomerCampaignService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + customer_account_service: CustomerAccountService, + audit_service: CommercialAuditService, + ) -> None: + self.repository = repository + self.customer_accounts = customer_account_service + self.audit = audit_service + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _validate_status(self, status: str) -> str: + normalized = str(status or "").strip() + if normalized not in CAMPAIGN_ACTIVATION_STATUSES: + raise ValueError("campaign_activation_status_invalid:%s" % normalized) + return normalized + + def _validate_review_status(self, status: str) -> str: + normalized = str(status or "").strip() + if normalized not in CAMPAIGN_REVIEW_STATUSES: + raise ValueError("campaign_review_status_invalid:%s" % normalized) + return normalized + + def _campaign_counts(self, *, customer_account_id: str) -> int: + return len(self.repository.list_campaigns(customer_account_id=customer_account_id, limit=500)) + + def _sync_customer_campaign_count(self, *, customer_account_id: str) -> None: + customer = self.repository.get_customer_account(customer_account_id) + count = self._campaign_counts(customer_account_id=customer_account_id) + self.repository.save_customer_account({**customer, "campaign_count": count}) + + def _campaign_bundle(self, campaign_id: str) -> Dict[str, Any]: + campaign = self.repository.get_campaign(campaign_id) + proof_bundles = self.repository.list_campaign_proof_bundles(campaign_id=campaign_id) + channel_targets = self.repository.list_campaign_channel_targets(campaign_id=campaign_id) + submissions = self.repository.list_campaign_review_submissions(campaign_id=campaign_id, limit=20) + review_case = None + if campaign.get("primary_review_case_id"): + try: + review_case = self.repository.get_review_case(campaign["primary_review_case_id"]) + except KeyError: + review_case = None + return { + "campaign": campaign, + "proof_bundles": proof_bundles, + "channel_targets": channel_targets, + "review_submissions": submissions, + "review_case": review_case, + } + + def create_or_update_campaign( + self, + *, + account_id: str, + payload: Dict[str, Any], + ) -> Dict[str, Any]: + customer_detail = self.customer_accounts.customer_account_detail(account_id=account_id) + customer = dict(customer_detail.get("customer_account") or {}) + existing = None + campaign_id = str(payload.get("campaign_id") or "").strip() or None + if campaign_id: + existing = self.repository.get_campaign(campaign_id) + if str(existing.get("account_id") or "") != account_id: + raise PermissionError("campaign_account_ownership_mismatch") + record = self.repository.save_campaign( + { + "campaign_id": campaign_id, + "customer_account_id": customer["customer_account_id"], + "account_id": account_id, + "title": str(payload.get("title") or ((existing or {}).get("title") or "")), + "target_icp_vertical": str(payload.get("target_icp_vertical") or ((existing or {}).get("target_icp_vertical") or "")), + "cta_text": str(payload.get("cta_text") or ((existing or {}).get("cta_text") or "")), + "disclosure_text": str(payload.get("disclosure_text") or ((existing or {}).get("disclosure_text") or "")), + "activation_status": self._validate_status(str(payload.get("activation_status") or (existing.get("activation_status") if existing else "draft") or "draft")), + "selected_channels": list(payload.get("selected_channels") or (existing.get("selected_channels_json") if existing else []) or []), + "selected_partner_refs": list(payload.get("selected_partner_refs") or (existing.get("selected_partner_refs_json") if existing else []) or []), + "primary_review_case_id": existing.get("primary_review_case_id") if existing else None, + "latest_submission_id": existing.get("latest_submission_id") if existing else None, + "campaign_payload": { + "proof_points": list(payload.get("proof_points") or []), + "proof_source_urls": list(payload.get("proof_source_urls") or []), + "proof_artifact_refs": list(payload.get("proof_artifact_refs") or []), + "selected_channels": list(payload.get("selected_channels") or []), + "selected_partner_refs": list(payload.get("selected_partner_refs") or []), + }, + } + ) + proof_bundle_payload = { + "bundle_label": "default", + "proof_points": list(payload.get("proof_points") or []), + "source_urls": list(payload.get("proof_source_urls") or []), + "artifact_refs": list(payload.get("proof_artifact_refs") or []), + "bundle_payload": {"campaign_id": record["campaign_id"]}, + } + self.repository.replace_campaign_proof_bundles(campaign_id=record["campaign_id"], bundles=[proof_bundle_payload]) + channel_targets = [] + selected_channels = list(payload.get("selected_channels") or []) + selected_partner_refs = list(payload.get("selected_partner_refs") or []) + for index, channel in enumerate(selected_channels): + channel_targets.append( + { + "channel_name": str(channel), + "partner_ref": selected_partner_refs[index] if index < len(selected_partner_refs) else None, + "priority": index, + "readiness_status": "selected", + "target_payload": {}, + } + ) + self.repository.replace_campaign_channel_targets(campaign_id=record["campaign_id"], targets=channel_targets) + self._sync_customer_campaign_count(customer_account_id=customer["customer_account_id"]) + bundle = self._campaign_bundle(record["campaign_id"]) + self.audit.record_audit_log( + actor_id=account_id, + actor_role="customer", + account_id=account_id, + object_type="campaign", + object_id=record["campaign_id"], + action_type="campaign_saved", + source_surface="customer", + customer_visible_payload={"campaign": bundle.get("campaign")}, + internal_payload=bundle, + ) + return bundle + + def _submission_validation_errors(self, bundle: Dict[str, Any]) -> List[str]: + campaign = dict(bundle.get("campaign") or {}) + proof_bundles = list(bundle.get("proof_bundles") or []) + channel_targets = list(bundle.get("channel_targets") or []) + errors: List[str] = [] + if not str(campaign.get("title") or "").strip(): + errors.append("campaign_title_required") + if not str(campaign.get("target_icp_vertical") or "").strip(): + errors.append("campaign_target_icp_vertical_required") + if not str(campaign.get("cta_text") or "").strip(): + errors.append("campaign_cta_required") + if not str(campaign.get("disclosure_text") or "").strip(): + errors.append("campaign_disclosure_required") + if not proof_bundles or not any((item.get("proof_points_json") or item.get("source_urls_json") or item.get("artifact_refs_json")) for item in proof_bundles): + errors.append("campaign_proof_bundle_required") + if not channel_targets: + errors.append("campaign_channel_selection_required") + return errors + + def submit_campaign(self, *, account_id: str, campaign_id: str) -> Dict[str, Any]: + bundle = self._campaign_bundle(campaign_id) + campaign = dict(bundle.get("campaign") or {}) + if str(campaign.get("account_id") or "") != account_id: + raise PermissionError("campaign_account_ownership_mismatch") + errors = self._submission_validation_errors(bundle) + if errors: + raise ValueError("campaign_submission_invalid:%s" % ",".join(errors)) + review_case = ReviewCase( + case_id=f"review_case_campaign_{campaign_id}", + case_type="campaign_activation", + status="open", + owner_id=None, + source_ref={"kind": "campaign", "campaign_id": campaign_id, "account_id": account_id}, + reason_codes=["campaign_activation_review"], + evidence_refs=[{"kind": "campaign", "ref_id": campaign_id}], + metadata={"activation_status": "in_review"}, + ) + saved_case = self.repository.save_review_case( + { + **review_case.to_dict(), + "source_surface": "customer", + "world_version_id": None, + "session_id": None, + "score_id": None, + "case_payload": { + "campaign_id": campaign_id, + "activation_status": "in_review", + }, + } + ) + submission = self.repository.save_campaign_review_submission( + { + "campaign_id": campaign_id, + "review_case_id": saved_case["case_id"], + "status": "submitted", + "submitted_by": account_id, + "submission_payload": { + "campaign_snapshot": campaign, + "proof_bundles": bundle.get("proof_bundles") or [], + "channel_targets": bundle.get("channel_targets") or [], + }, + } + ) + self.repository.save_campaign( + { + **campaign, + "activation_status": "in_review", + "primary_review_case_id": saved_case["case_id"], + "latest_submission_id": submission["submission_id"], + "selected_channels_json": campaign.get("selected_channels_json", []), + "selected_partner_refs_json": campaign.get("selected_partner_refs_json", []), + "campaign_payload_json": campaign.get("campaign_payload_json", {}), + } + ) + result = { + **self._campaign_bundle(campaign_id), + "submission": submission, + } + self.audit.record_audit_log( + actor_id=account_id, + actor_role="customer", + account_id=account_id, + object_type="campaign", + object_id=campaign_id, + action_type="campaign_submitted", + source_surface="customer", + customer_visible_payload={"campaign": result.get("campaign"), "submission": submission}, + internal_payload=result, + ) + return result + + def decide_campaign(self, *, campaign_id: str, reviewer_id: str, decision: str, note: Optional[str] = None) -> Dict[str, Any]: + normalized_decision = str(decision or "").strip() + if normalized_decision not in {"approve", "activate", "needs_changes", "block", "pause"}: + raise ValueError("campaign_decision_invalid") + bundle = self._campaign_bundle(campaign_id) + campaign = dict(bundle.get("campaign") or {}) + submissions = list(bundle.get("review_submissions") or []) + latest_submission = submissions[0] if submissions else None + if latest_submission is None: + raise ValueError("campaign_submission_missing") + review_case_id = latest_submission.get("review_case_id") or campaign.get("primary_review_case_id") + activation_status = { + "approve": "approved", + "activate": "active", + "needs_changes": "draft", + "block": "blocked", + "pause": "paused", + }[normalized_decision] + submission_status = { + "approve": "approved", + "activate": "activated", + "needs_changes": "needs_changes", + "block": "blocked", + "pause": "paused", + }[normalized_decision] + if review_case_id: + case_status = "resolved" if normalized_decision in {"approve", "activate", "needs_changes", "block", "pause"} else "open" + self.repository.update_review_case_status(review_case_id, status=case_status, owner_id=reviewer_id) + self.repository.save_campaign_review_submission( + { + **latest_submission, + "status": self._validate_review_status(submission_status), + "reviewer_id": reviewer_id, + "decision_note": note, + "decided_at": self._utcnow(), + "submission_payload_json": latest_submission.get("submission_payload_json", {}), + } + ) + self.repository.save_campaign( + { + **campaign, + "activation_status": activation_status, + "selected_channels_json": campaign.get("selected_channels_json", []), + "selected_partner_refs_json": campaign.get("selected_partner_refs_json", []), + "campaign_payload_json": campaign.get("campaign_payload_json", {}), + } + ) + result = self._campaign_bundle(campaign_id) + self.audit.record_audit_log( + actor_id=reviewer_id, + actor_role="reviewer", + account_id=campaign.get("account_id"), + object_type="campaign", + object_id=campaign_id, + action_type="campaign_decided", + source_surface="ops", + customer_visible_payload={"campaign": result.get("campaign")}, + internal_payload={**result, "decision": normalized_decision, "note": note}, + ) + return result + + def list_campaigns(self, *, account_id: str, limit: int = 50) -> Dict[str, Any]: + campaigns = [self._campaign_bundle(item["campaign_id"]) for item in self.repository.list_campaigns(account_id=account_id, limit=limit)] + status_counts = Counter(str(item["campaign"].get("activation_status") or "unknown") for item in campaigns) + return { + "campaigns": campaigns, + "summary": { + "campaign_count": len(campaigns), + "status_counts": dict(status_counts), + }, + } + + def campaign_detail(self, *, account_id: str, campaign_id: str) -> Dict[str, Any]: + bundle = self._campaign_bundle(campaign_id) + if str((bundle.get("campaign") or {}).get("account_id") or "") != account_id: + raise PermissionError("campaign_account_ownership_mismatch") + return bundle diff --git a/src/narrativeos/services/customer_success_reporting.py b/src/narrativeos/services/customer_success_reporting.py new file mode 100644 index 0000000..e84778e --- /dev/null +++ b/src/narrativeos/services/customer_success_reporting.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +from collections import Counter +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from .commercial_audit import CommercialAuditService +from .customer_accounts import CustomerAccountService +from .customer_workspace import CustomerWorkspaceService +from .production_acceptance import ProductionAcceptanceService +from .production_signoff import ProductionSignoffService +from ..persistence.repositories import SQLAlchemyPlatformRepository + + +READINESS_WEIGHTS = { + "value_delivery": 30, + "billing_health": 25, + "operational_stability": 20, + "product_continuity": 15, + "expansion_renewal": 10, +} + + +class CustomerSuccessReportingService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + customer_workspace_service: CustomerWorkspaceService, + customer_account_service: CustomerAccountService, + production_acceptance_service: ProductionAcceptanceService, + production_signoff_service: ProductionSignoffService, + commercial_audit_service: CommercialAuditService, + ) -> None: + self.repository = repository + self.customer_workspace = customer_workspace_service + self.customer_accounts = customer_account_service + self.production_acceptance = production_acceptance_service + self.production_signoff = production_signoff_service + self.commercial_audit = commercial_audit_service + + def _parse_dt(self, value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + def _utcnow(self) -> datetime: + return datetime.now(timezone.utc) + + def _utcnow_iso(self) -> str: + return self._utcnow().isoformat() + + def _active_launch_anchor(self, *, launch_wave: str, fallback: Optional[str]) -> Optional[str]: + audits = self.repository.list_audit_logs(action_type="launch_wave_status_updated", limit=500) + active_candidates: List[datetime] = [] + for row in audits: + internal = dict(row.get("internal_payload_json") or {}) + if str(internal.get("launch_wave") or "") != launch_wave: + continue + if str(internal.get("status") or "") != "active": + continue + parsed = self._parse_dt(row.get("created_at")) + if parsed: + active_candidates.append(parsed) + if active_candidates: + return min(active_candidates).isoformat() + return fallback + + def _latest_by_account(self, rows: List[Dict[str, Any]], *, account_key: str = "account_id", generated_key: str = "generated_at") -> Dict[str, Dict[str, Any]]: + latest: Dict[str, Dict[str, Any]] = {} + for row in rows: + account_id = str(row.get(account_key) or "") + if not account_id: + continue + if account_id not in latest or str(row.get(generated_key) or "") > str(latest[account_id].get(generated_key) or ""): + latest[account_id] = row + return latest + + def _readiness_components(self, *, workspace: Dict[str, Any], account_detail: Dict[str, Any], acceptance_record: Dict[str, Any], signoff: Optional[Dict[str, Any]]) -> Dict[str, float]: + handoff = dict(workspace.get("handoff_conversion_summary") or {}) + support = dict(workspace.get("support_summary") or {}) + disputes = dict(workspace.get("dispute_summary") or {}) + billing_profile = dict(workspace.get("billing_profile") or {}) + invoice_preview = dict(workspace.get("invoice_preview") or {}) + expansion = dict(workspace.get("expansion_summary") or {}) + renewal = dict(workspace.get("renewal_summary") or {}) + quality = dict(workspace.get("quality_summary") or {}) + launch_alert_count = 0 + value_delivery = 1.0 if (int(handoff.get("validated_conversion_count") or 0) > 0 or int(handoff.get("validated_handoff_count") or 0) > 0) else 0.4 + billing_health = 1.0 if billing_profile.get("billing_profile_id") and invoice_preview.get("invoice_preview_id") else 0.3 + operational_stability = 1.0 if int(support.get("case_count") or 0) == 0 and int(disputes.get("dispute_count") or 0) == 0 else 0.5 + product_continuity = 1.0 if int(quality.get("event_count") or 0) > 0 and acceptance_record.get("status") != "blocked" else 0.5 + expansion_renewal = 1.0 if expansion.get("recommended_plan_id") or renewal.get("status") in {"renewal_due", "active", "stable"} else 0.4 + if signoff and str((signoff.get("signoff") or {}).get("status") or "") != "fully_signed": + operational_stability = min(operational_stability, 0.6) + return { + "value_delivery": value_delivery, + "billing_health": billing_health, + "operational_stability": operational_stability, + "product_continuity": product_continuity, + "expansion_renewal": expansion_renewal, + "launch_alert_count": float(launch_alert_count), + } + + def _score_band(self, score: float) -> str: + if score >= 75.0: + return "ready" + if score >= 50.0: + return "watch" + return "at_risk" + + def _build_account_snapshots(self, *, account_id: str, launch_wave: str) -> Dict[str, Any]: + workspace = self.customer_workspace.workspace(account_id=account_id) + account_detail = self.customer_accounts.customer_account_detail(account_id=account_id) + acceptance_record = next(iter(self.repository.list_production_customer_acceptance_records(account_id=account_id, launch_wave=launch_wave, limit=1)), None) + if acceptance_record is None: + raise KeyError(f"missing_production_acceptance_for_account:{account_id}") + signoff = None + if acceptance_record.get("signoff_id"): + try: + signoff = self.production_signoff.signoff_detail(signoff_id=acceptance_record["signoff_id"]) + except KeyError: + signoff = None + fallback_anchor = acceptance_record.get("created_at") + launch_anchor_at = self._active_launch_anchor(launch_wave=launch_wave, fallback=fallback_anchor) + payment_transactions = self.repository.list_payment_transactions(account_id=account_id, limit=500) + invoice_issuances = self.repository.list_invoice_issuances(account_id=account_id, limit=500) + dunning_runs = self.repository.list_dunning_runs(account_id=account_id, limit=500) + support_cases = self.repository.list_support_cases(account_id=account_id, limit=500) + disputes = self.repository.list_disputes(account_id=account_id, limit=500) + partner_channels = dict((workspace.get("channel_partner_performance") or {})).get("allowlisted_channels") or [] + handoff = dict(workspace.get("handoff_conversion_summary") or {}) + receipt = dict(workspace.get("receipt_summary") or {}) + expansion = dict(workspace.get("expansion_summary") or {}) + renewal = dict(workspace.get("renewal_summary") or {}) + components = self._readiness_components( + workspace=workspace, + account_detail=account_detail, + acceptance_record=acceptance_record, + signoff=signoff, + ) + score = sum(components[key] * READINESS_WEIGHTS[key] for key in READINESS_WEIGHTS) + band = self._score_band(score) + first_7_day_payload = { + "billing_outcome": { + "invoice_count": len(invoice_issuances), + "payment_failure_count": len([item for item in payment_transactions if str(item.get("status") or "") == "failed"]), + "payment_paid_count": len([item for item in payment_transactions if str(item.get("status") or "") == "paid"]), + }, + "support_dispute": { + "support_case_count": len(support_cases), + "dispute_count": len(disputes), + }, + "value_delivery": { + "receipt_count": int(receipt.get("receipt_count") or 0), + "validated_handoff_count": int(handoff.get("validated_handoff_count") or 0), + "validated_conversion_count": int(handoff.get("validated_conversion_count") or 0), + }, + "lifecycle": { + "dunning_open_count": len([item for item in dunning_runs if str(item.get("status") or "") == "open"]), + "renewal_status": renewal.get("status"), + "upgrade_recommendation": expansion.get("recommended_plan_id"), + }, + "launch_alert_count": 0, + } + first_30_payload = { + "usage_billing_realized": { + "invoice_count": len(invoice_issuances), + "total_due_usd": float(sum(float(item.get("total_due_usd") or 0.0) for item in invoice_issuances)), + }, + "partner_campaign_posture": { + "campaign_count": int((workspace.get("campaign_summary") or {}).get("campaign_count") or 0), + "allowlisted_channels": list(partner_channels), + }, + "support_burden": { + "support_case_count": len(support_cases), + "dispute_count": len(disputes), + }, + "renewal_expansion": { + "renewal_status": renewal.get("status"), + "expansion_status": expansion.get("status"), + "recommended_plan_id": expansion.get("recommended_plan_id"), + }, + } + provisional = True + if launch_anchor_at: + anchor_dt = self._parse_dt(launch_anchor_at) + provisional = not bool(anchor_dt and (self._utcnow() - anchor_dt).days >= 30) + snapshot_payload = { + "account_id": account_id, + "launch_wave": launch_wave, + "launch_anchor_at": launch_anchor_at, + "customer_workspace": { + "quality_summary": workspace.get("quality_summary"), + "receipt_summary": workspace.get("receipt_summary"), + "handoff_conversion_summary": workspace.get("handoff_conversion_summary"), + "renewal_summary": workspace.get("renewal_summary"), + "dunning_summary": workspace.get("dunning_summary"), + "expansion_summary": workspace.get("expansion_summary"), + "support_summary": workspace.get("support_summary"), + "dispute_summary": workspace.get("dispute_summary"), + }, + "production_acceptance": acceptance_record, + "production_signoff": { + "signoff_id": (signoff or {}).get("signoff", {}).get("signoff_id"), + "status": (signoff or {}).get("signoff", {}).get("status"), + }, + "pilot_to_paid_readiness": { + "score": score, + "band": band, + "components": {key: {"normalized": components[key], "weight": READINESS_WEIGHTS[key], "weighted_score": components[key] * READINESS_WEIGHTS[key]} for key in READINESS_WEIGHTS}, + }, + } + return { + "launch_anchor_at": launch_anchor_at, + "first_7_day_payload": first_7_day_payload, + "first_30_day_payload": first_30_payload, + "provisional": provisional, + "score": score, + "band": band, + "score_payload": snapshot_payload["pilot_to_paid_readiness"], + "snapshot_payload": snapshot_payload, + "customer_account_id": (account_detail.get("customer_account") or {}).get("customer_account_id"), + } + + def sync_snapshots( + self, + *, + actor_id: str, + actor_role: str, + account_id: Optional[str] = None, + launch_wave: Optional[str] = None, + ) -> Dict[str, Any]: + candidates = self.repository.list_go_live_ready_accounts(account_id=account_id, launch_wave=launch_wave, limit=100) + saved: List[Dict[str, Any]] = [] + for item in candidates: + account = str(item.get("account_id") or "") + wave = str(item.get("launch_wave") or "") + if not account or not wave: + continue + built = self._build_account_snapshots(account_id=account, launch_wave=wave) + first7 = self.repository.save_first_7_day_outcome( + { + "account_id": account, + "customer_account_id": built["customer_account_id"], + "launch_wave": wave, + "launch_anchor_at": built["launch_anchor_at"], + "outcome_payload": built["first_7_day_payload"], + } + ) + first30 = self.repository.save_first_30_day_value_summary( + { + "account_id": account, + "customer_account_id": built["customer_account_id"], + "launch_wave": wave, + "launch_anchor_at": built["launch_anchor_at"], + "provisional": built["provisional"], + "summary_payload": built["first_30_day_payload"], + } + ) + score = self.repository.save_pilot_to_paid_readiness_score( + { + "account_id": account, + "customer_account_id": built["customer_account_id"], + "launch_wave": wave, + "launch_anchor_at": built["launch_anchor_at"], + "score": built["score"], + "band": built["band"], + "score_payload": built["score_payload"], + } + ) + snapshot = self.repository.save_customer_success_snapshot( + { + "account_id": account, + "customer_account_id": built["customer_account_id"], + "launch_wave": wave, + "launch_anchor_at": built["launch_anchor_at"], + "snapshot_payload": built["snapshot_payload"], + } + ) + saved.append({"first_7_day_outcome": first7, "first_30_day_value_summary": first30, "pilot_to_paid_readiness_score": score, "customer_success_snapshot": snapshot}) + self.commercial_audit.record_audit_log( + actor_id=actor_id, + actor_role=actor_role, + account_id=account, + object_type="customer_success_snapshot", + object_id=snapshot["customer_success_snapshot_id"], + action_type="customer_success_snapshot_synced", + source_surface="ops", + customer_visible_payload={}, + internal_payload=saved[-1], + ) + return { + "synced": saved, + "summary": { + "snapshot_count": len(saved), + "account_ids": [item["customer_success_snapshot"]["account_id"] for item in saved], + "band_counts": dict(Counter(str(item["pilot_to_paid_readiness_score"]["band"] or "unknown") for item in saved)), + }, + } + + def _latest_snapshot_bundle(self, *, account_id: Optional[str] = None, launch_wave: Optional[str] = None) -> Dict[str, Dict[str, Any]]: + return { + "first_7_day": self._latest_by_account(self.repository.list_first_7_day_outcomes(account_id=account_id, launch_wave=launch_wave, limit=200)), + "first_30_day": self._latest_by_account(self.repository.list_first_30_day_value_summaries(account_id=account_id, launch_wave=launch_wave, limit=200)), + "scores": self._latest_by_account(self.repository.list_pilot_to_paid_readiness_scores(account_id=account_id, launch_wave=launch_wave, limit=200)), + "snapshots": self._latest_by_account(self.repository.list_customer_success_snapshots(account_id=account_id, launch_wave=launch_wave, limit=200)), + } + + def list_customer_success(self, *, account_id: Optional[str] = None, launch_wave: Optional[str] = None, limit: int = 50) -> Dict[str, Any]: + latest = self._latest_snapshot_bundle(account_id=account_id, launch_wave=launch_wave) + account_ids = list(latest["snapshots"].keys())[:limit] + records = [] + for account in account_ids: + records.append( + { + "account_id": account, + "snapshot": latest["snapshots"].get(account), + "first_7_day_outcome": latest["first_7_day"].get(account), + "first_30_day_value_summary": latest["first_30_day"].get(account), + "pilot_to_paid_readiness_score": latest["scores"].get(account), + } + ) + bands = Counter(str((item.get("pilot_to_paid_readiness_score") or {}).get("band") or "unknown") for item in records) + provisional_count = sum(1 for item in records if bool((item.get("first_30_day_value_summary") or {}).get("provisional"))) + return { + "accounts": records, + "summary": { + "account_count": len(records), + "band_counts": dict(bands), + "provisional_30_day_count": provisional_count, + "launch_wave_count": len({str((item.get("snapshot") or {}).get("launch_wave") or "") for item in records if (item.get("snapshot") or {}).get("launch_wave")}), + }, + } + + def _detail(self, *, account_id: str) -> Dict[str, Any]: + latest = self._latest_snapshot_bundle(account_id=account_id) + snapshot = next(iter(latest["snapshots"].values()), None) + first7 = next(iter(latest["first_7_day"].values()), None) + first30 = next(iter(latest["first_30_day"].values()), None) + score = next(iter(latest["scores"].values()), None) + return { + "account_id": account_id, + "customer_success_snapshot": snapshot, + "first_7_day_outcome": first7, + "first_30_day_value_summary": first30, + "pilot_to_paid_readiness_score": score, + } + + def detail(self, *, account_id: str) -> Dict[str, Any]: + return self._detail(account_id=account_id) + + def report(self, *, account_id: Optional[str] = None, launch_wave: Optional[str] = None, view: str = "internal") -> Dict[str, Any]: + normalized_view = str(view or "internal") + if normalized_view == "investor_safe": + listing = self.list_customer_success(launch_wave=launch_wave, limit=200) + scores = [float((item.get("pilot_to_paid_readiness_score") or {}).get("score") or 0.0) for item in listing["accounts"]] + return { + "view": "investor_safe", + "launch_wave": launch_wave, + "summary": { + "account_count": listing["summary"]["account_count"], + "band_counts": listing["summary"]["band_counts"], + "provisional_30_day_count": listing["summary"]["provisional_30_day_count"], + "avg_readiness_score": round(sum(scores) / len(scores), 3) if scores else 0.0, + }, + } + if not account_id: + raise KeyError("customer_success_account_id_required") + detail = self._detail(account_id=account_id) + if normalized_view == "customer_safe": + return { + "view": "customer_safe", + "account_id": account_id, + "customer_success_snapshot": self.commercial_audit.customer_safe_payload(detail.get("customer_success_snapshot") or {}), + "first_7_day_outcome": self.commercial_audit.customer_safe_payload(detail.get("first_7_day_outcome") or {}), + "first_30_day_value_summary": self.commercial_audit.customer_safe_payload(detail.get("first_30_day_value_summary") or {}), + "pilot_to_paid_readiness_score": self.commercial_audit.customer_safe_payload(detail.get("pilot_to_paid_readiness_score") or {}), + } + return { + "view": "internal", + **detail, + } diff --git a/src/narrativeos/services/customer_workspace.py b/src/narrativeos/services/customer_workspace.py new file mode 100644 index 0000000..b84d9bf --- /dev/null +++ b/src/narrativeos/services/customer_workspace.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import base64 +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from .commercial_billing import CommercialBillingService +from .commercial_audit import CommercialAuditService +from .commercial_lifecycle_automation import CommercialLifecycleAutomationService +from .commercial_support import CommercialSupportService +from .customer_accounts import CustomerAccountService +from .customer_campaigns import CustomerCampaignService +from .observability import ObservabilityService +from .ops_quality_projection import OpsQualityProjectionService +from .partner_readiness import PartnerReadinessService + + +class CustomerWorkspaceService: + def __init__( + self, + *, + customer_account_service: CustomerAccountService, + customer_campaign_service: CustomerCampaignService, + partner_readiness_service: PartnerReadinessService, + commercial_billing_service: CommercialBillingService, + commercial_audit_service: CommercialAuditService, + commercial_support_service: CommercialSupportService, + commercial_lifecycle_automation_service: CommercialLifecycleAutomationService, + quality_projection_service: OpsQualityProjectionService, + observability_service: ObservabilityService, + ) -> None: + self.customer_accounts = customer_account_service + self.customer_campaigns = customer_campaign_service + self.partner_readiness = partner_readiness_service + self.commercial_billing = commercial_billing_service + self.commercial_audit = commercial_audit_service + self.commercial_support = commercial_support_service + self.commercial_lifecycle = commercial_lifecycle_automation_service + self.quality_projection = quality_projection_service + self.observability = observability_service + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _workspace_payload(self, *, account_id: str, period_start: Optional[str] = None) -> Dict[str, Any]: + account_detail = self.customer_accounts.customer_account_detail(account_id=account_id) + campaign_bundle = self.customer_campaigns.list_campaigns(account_id=account_id, limit=20) + invoice_preview = self.commercial_billing.invoice_preview(account_id=account_id, period_start=period_start) + disputes = self.commercial_support.list_disputes(account_id=account_id, limit=25) + support_cases = self.commercial_support.list_support_cases(account_id=account_id, limit=25) + lifecycle_state = self.commercial_lifecycle.sync_account(account_id=account_id) + quality_summary = self.quality_projection.quality_summary(account_id=account_id, limit=25) + receipts = self.observability.list_runtime_receipts(account_id=account_id, limit=25) + receipt_summary = self.observability.runtime_incident_snapshot(account_id=account_id, limit=10) + latest_quality_events = list((quality_summary.get("events") or [])[:5]) + latest_receipts = receipts[:5] + line_items = list((invoice_preview.get("invoice_preview") or {}).get("line_items_json") or []) + lifecycle_summary = account_detail.get("lifecycle_summary") or {} + lifecycle_automation_summary = dict((lifecycle_state.get("summary") or {})) + renewal_tracker = dict(lifecycle_state.get("renewal_tracker") or {}) + dunning_run = dict(lifecycle_state.get("dunning_run") or {}) + pilot_track = dict(lifecycle_state.get("pilot_conversion_track") or {}) + expansion_candidate = dict(lifecycle_state.get("expansion_candidate") or {}) + churn_flag = dict(lifecycle_state.get("churn_risk_flag") or {}) + partner_mix: Dict[str, int] = {} + allowlisted_channels = set() + for detail in campaign_bundle.get("campaigns") or []: + for target in detail.get("channel_targets") or []: + partner_ref = str(target.get("partner_ref") or "").strip() + if partner_ref: + try: + partner_detail = self.partner_readiness.partner_detail(partner_ref) + lifecycle = str((partner_detail.get("partner") or {}).get("lifecycle_status") or "unknown") + partner_mix[lifecycle] = partner_mix.get(lifecycle, 0) + 1 + allowlisted_channels.update(list((partner_detail.get("partner") or {}).get("allowlisted_channels_json") or [])) + except KeyError: + partner_mix["unknown"] = partner_mix.get("unknown", 0) + 1 + channel_partner_performance = { + "channel_mix": dict((receipt_summary.get("by_surface") or {})), + "partner_mix": partner_mix, + "provider_mix": dict((receipt_summary.get("by_provider") or {})), + "allowlisted_channels": sorted(allowlisted_channels), + } + handoff_conversion_summary = { + "validated_presented_count": int((invoice_preview.get("usage_ledger") or {}).get("presented_count") or 0), + "validated_handoff_count": int((invoice_preview.get("usage_ledger") or {}).get("handoff_count") or 0), + "validated_conversion_count": int((invoice_preview.get("usage_ledger") or {}).get("conversion_count") or 0), + "line_items": line_items, + } + dunning_payload = dict(dunning_run.get("dunning_payload_json") or {}) + expansion_payload = dict(expansion_candidate.get("candidate_payload_json") or {}) + churn_payload = dict(churn_flag.get("flag_payload_json") or {}) + renewal_summary = { + "status": renewal_tracker.get("status") or lifecycle_automation_summary.get("renewal_status") or lifecycle_summary.get("renewal_tracker_status") or "stable", + "renewal_due_at": lifecycle_summary.get("renewal_due_at"), + "renewal_risk": lifecycle_summary.get("renewal_risk"), + "customer_status": (account_detail.get("customer_account") or {}).get("status"), + } + dunning_summary = { + "status": lifecycle_automation_summary.get("dunning_status") or lifecycle_summary.get("dunning_status") or "clear", + "current_step": dunning_run.get("current_step"), + "invoice_id": dunning_run.get("invoice_id"), + "invoice_due_usd": dunning_payload.get("total_due_usd") or (invoice_preview.get("invoice_preview") or {}).get("total_due_usd") or 0.0, + "retry_count": dunning_payload.get("retry_count") or 0, + "hosted_invoice_url": dunning_payload.get("hosted_invoice_url"), + "invoice_pdf_url": dunning_payload.get("invoice_pdf_url"), + } + pilot_conversion_summary = { + "status": pilot_track.get("status") or lifecycle_automation_summary.get("pilot_conversion_status") or lifecycle_summary.get("pilot_conversion_status") or "watch", + "active_campaign_count": (dict(pilot_track.get("track_payload_json") or {})).get("active_campaign_count") or 0, + "validated_billable_count": (dict(pilot_track.get("track_payload_json") or {})).get("validated_billable_count") or 0, + } + expansion_summary = { + "status": expansion_candidate.get("status") or lifecycle_automation_summary.get("expansion_status") or lifecycle_summary.get("expansion_status") or "clear", + "trigger_type": expansion_candidate.get("trigger_type"), + "recommended_plan_id": expansion_payload.get("recommended_plan_id") or lifecycle_automation_summary.get("recommended_plan_id") or lifecycle_summary.get("upgrade_recommendation_plan_id"), + "active_overage_flag_count": expansion_payload.get("active_overage_flag_count") or 0, + "invoice_due_usd": expansion_payload.get("invoice_due_usd") or 0.0, + } + churn_risk_summary = { + "status": churn_flag.get("status") or lifecycle_automation_summary.get("churn_risk_status") or lifecycle_summary.get("churn_risk_status") or "stable", + "risk_level": churn_flag.get("risk_level") or lifecycle_automation_summary.get("churn_risk_level") or lifecycle_summary.get("churn_risk_level") or "low", + "open_disputes": churn_payload.get("open_disputes") or 0, + "open_support_cases": churn_payload.get("open_support_cases") or 0, + "latest_invoice_status": churn_payload.get("latest_invoice_status"), + } + campaign_summary = { + "campaign_count": int((account_detail.get("customer_account") or {}).get("campaign_count") or 0), + "campaign_limit": int((account_detail.get("customer_account") or {}).get("campaign_limit") or 0), + "status_counts": dict((campaign_bundle.get("summary") or {}).get("status_counts") or {}), + "activation_status": "active" if dict((campaign_bundle.get("summary") or {}).get("status_counts") or {}).get("active") else "campaign_workflow_pending", + } + return { + "generated_at": self._utcnow(), + "customer_account": account_detail.get("customer_account"), + "plan": account_detail.get("plan"), + "billing_profile": account_detail.get("billing_profile"), + "lifecycle_summary": account_detail.get("lifecycle_summary"), + "limit_posture": account_detail.get("limit_posture"), + "campaign_summary": campaign_summary, + "campaigns": [item.get("campaign") for item in campaign_bundle.get("campaigns") or []], + "campaign_details": campaign_bundle.get("campaigns") or [], + "channel_partner_performance": channel_partner_performance, + "quality_summary": quality_summary.get("summary", {}), + "groundedness_summary": quality_summary.get("groundedness_summary", {}), + "feedback_summary": quality_summary.get("feedback_summary", {}), + "receipt_summary": { + "receipt_count": receipt_summary.get("receipt_count"), + "incident_count": receipt_summary.get("incident_count"), + "by_provider": receipt_summary.get("by_provider", {}), + "by_surface": receipt_summary.get("by_surface", {}), + "latency_summary": receipt_summary.get("latency_summary", {}), + "latest_receipts": latest_receipts, + }, + "handoff_conversion_summary": handoff_conversion_summary, + "invoice_preview": invoice_preview.get("invoice_preview"), + "credit_balance": invoice_preview.get("credit_balance"), + "overage_flags": invoice_preview.get("overage_flags", []), + "dispute_summary": disputes.get("summary", {}), + "disputes": disputes.get("disputes", []), + "support_summary": support_cases.get("summary", {}), + "support_cases": support_cases.get("support_cases", []), + "renewal_summary": renewal_summary, + "dunning_summary": dunning_summary, + "pilot_conversion_summary": pilot_conversion_summary, + "expansion_summary": expansion_summary, + "churn_risk_summary": churn_risk_summary, + "lifecycle_automation": lifecycle_state, + "latest_quality_events": latest_quality_events, + "exports": { + "available_reports": [ + "workspace_json", + "workspace_csv", + "workspace_pdf", + "invoice_csv", + ] + }, + } + + def workspace(self, *, account_id: str, period_start: Optional[str] = None) -> Dict[str, Any]: + payload = self._workspace_payload(account_id=account_id, period_start=period_start) + payload["linked_traces"] = [ + { + "trace_id": item.get("trace_id"), + "status": item.get("status"), + "source_surface": item.get("source_surface"), + "overall_score": item.get("overall_score"), + "grounding_status": item.get("grounding_status"), + } + for item in list(payload.get("latest_quality_events") or [])[:5] + if item.get("trace_id") + ] + return payload + + def campaign_report( + self, + *, + account_id: str, + campaign_id: str, + period_start: Optional[str] = None, + ) -> Dict[str, Any]: + payload = self.workspace(account_id=account_id, period_start=period_start) + bundle = self.customer_campaigns.campaign_detail(account_id=account_id, campaign_id=campaign_id) + payload["campaign_summary"] = { + **dict(payload.get("campaign_summary") or {}), + "requested_campaign_id": campaign_id, + "campaign_available": True, + "activation_status": (bundle.get("campaign") or {}).get("activation_status"), + } + payload["selected_campaign"] = bundle + return payload + + def _csv_rows(self, payload: Dict[str, Any]) -> List[str]: + rows = [ + "section,key,value", + f"account,account_id,{(payload.get('customer_account') or {}).get('account_id') or ''}", + f"account,status,{(payload.get('lifecycle_summary') or {}).get('status') or ''}", + f"plan,plan_id,{(payload.get('plan') or {}).get('plan_id') or ''}", + f"plan,display_name,{(payload.get('plan') or {}).get('display_name') or ''}", + f"lifecycle,renewal_status,{(payload.get('renewal_summary') or {}).get('status') or ''}", + f"lifecycle,dunning_status,{(payload.get('dunning_summary') or {}).get('status') or ''}", + f"lifecycle,pilot_conversion_status,{(payload.get('pilot_conversion_summary') or {}).get('status') or ''}", + f"lifecycle,expansion_status,{(payload.get('expansion_summary') or {}).get('status') or ''}", + f"lifecycle,churn_risk_level,{(payload.get('churn_risk_summary') or {}).get('risk_level') or ''}", + f"quality,event_count,{(payload.get('quality_summary') or {}).get('event_count') or 0}", + f"quality,open_review_case_count,{(payload.get('quality_summary') or {}).get('open_review_case_count') or 0}", + f"groundedness,pass_rate,{(payload.get('groundedness_summary') or {}).get('pass_rate') or 0}", + f"groundedness,failed_count,{(payload.get('groundedness_summary') or {}).get('failed_count') or 0}", + f"receipts,receipt_count,{(payload.get('receipt_summary') or {}).get('receipt_count') or 0}", + f"handoff,validated_handoff_count,{(payload.get('handoff_conversion_summary') or {}).get('validated_handoff_count') or 0}", + f"conversion,validated_conversion_count,{(payload.get('handoff_conversion_summary') or {}).get('validated_conversion_count') or 0}", + f"invoice,total_due_usd,{(payload.get('invoice_preview') or {}).get('total_due_usd') or 0}", + ] + return rows + + def _pdf_bytes(self, *, title: str, lines: List[str]) -> bytes: + def _escape(value: str) -> str: + return str(value).replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + + y = 760 + content_lines = ["BT", "/F1 12 Tf"] + for line in [title, *lines]: + content_lines.append(f"50 {y} Td ({_escape(line)}) Tj") + y -= 18 + content_lines.append("ET") + content = "\n".join(content_lines).encode("utf-8") + objects = [ + b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n", + b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n", + b"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> endobj\n", + b"4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n", + f"5 0 obj << /Length {len(content)} >> stream\n".encode("utf-8") + content + b"\nendstream endobj\n", + ] + pdf = bytearray(b"%PDF-1.4\n") + offsets = [0] + for obj in objects: + offsets.append(len(pdf)) + pdf.extend(obj) + xref_offset = len(pdf) + pdf.extend(f"xref\n0 {len(objects)+1}\n".encode("utf-8")) + pdf.extend(b"0000000000 65535 f \n") + for offset in offsets[1:]: + pdf.extend(f"{offset:010d} 00000 n \n".encode("utf-8")) + pdf.extend( + f"trailer << /Size {len(objects)+1} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF".encode("utf-8") + ) + return bytes(pdf) + + def export_payload(self, *, account_id: str, report_type: str, period_start: Optional[str] = None) -> Dict[str, Any]: + payload = self.workspace(account_id=account_id, period_start=period_start) + if report_type == "workspace_json": + return { + "report_type": report_type, + "filename": f"customer_workspace_{account_id}.json", + "content_type": "application/json", + "content": payload, + } + if report_type == "workspace_csv": + return { + "report_type": report_type, + "filename": f"customer_workspace_{account_id}.csv", + "content_type": "text/csv", + "content": "\n".join(self._csv_rows(payload)), + } + if report_type == "invoice_csv": + invoice = self.commercial_billing.invoice_preview(account_id=account_id, period_start=period_start) + return { + "report_type": report_type, + "filename": f"invoice_preview_{account_id}.csv", + "content_type": "text/csv", + "content": str((invoice.get("export_artifacts") or {}).get("csv_preview") or ""), + } + if report_type == "workspace_pdf": + pdf_bytes = self._pdf_bytes( + title=f"Customer Workspace Report - {account_id}", + lines=[ + f"Plan: {(payload.get('plan') or {}).get('display_name') or '-'}", + f"Status: {(payload.get('lifecycle_summary') or {}).get('status') or '-'}", + f"Renewal: {(payload.get('renewal_summary') or {}).get('status') or '-'}", + f"Dunning: {(payload.get('dunning_summary') or {}).get('status') or '-'}", + f"Pilot conversion: {(payload.get('pilot_conversion_summary') or {}).get('status') or '-'}", + f"Upgrade recommendation: {(payload.get('expansion_summary') or {}).get('recommended_plan_id') or '-'}", + f"Quality events: {(payload.get('quality_summary') or {}).get('event_count') or 0}", + f"Groundedness pass rate: {(payload.get('groundedness_summary') or {}).get('pass_rate') or 0}", + f"Receipts: {(payload.get('receipt_summary') or {}).get('receipt_count') or 0}", + f"Validated handoff: {(payload.get('handoff_conversion_summary') or {}).get('validated_handoff_count') or 0}", + f"Validated conversion: {(payload.get('handoff_conversion_summary') or {}).get('validated_conversion_count') or 0}", + f"Invoice due: {(payload.get('invoice_preview') or {}).get('total_due_usd') or 0}", + ], + ) + return { + "report_type": report_type, + "filename": f"customer_workspace_{account_id}.pdf", + "content_type": "application/pdf", + "content_base64": base64.b64encode(pdf_bytes).decode("ascii"), + } + raise KeyError("unknown_customer_export:%s" % report_type) diff --git a/src/narrativeos/services/emailing.py b/src/narrativeos/services/emailing.py new file mode 100644 index 0000000..9889369 --- /dev/null +++ b/src/narrativeos/services/emailing.py @@ -0,0 +1,497 @@ +from __future__ import annotations + +from dataclasses import dataclass +from email.utils import parseaddr +import json +import os +from typing import Any, Dict, Optional + +import httpx + + +def _first_env(*keys: str, default: Optional[str] = None) -> Optional[str]: + for key in keys: + value = str(os.getenv(key, "") or "").strip() + if value: + return value + return default + + +def _normalize_mode(value: Optional[str]) -> str: + lowered = str(value or "test").strip().lower() + return "production" if lowered in {"production", "prod", "live"} else "test" + + +def _normalize_domain_status(value: Optional[str]) -> str: + lowered = str(value or "unverified").strip().lower() + if lowered in {"verified", "active", "ready"}: + return "verified" + if lowered in {"pending", "verifying"}: + return "pending" + return "unverified" + + +def _split_allowlist(value: Optional[str]) -> tuple[str, ...]: + entries = [] + for item in str(value or "").replace("\n", ",").split(","): + normalized = item.strip().lower() + if normalized: + entries.append(normalized) + return tuple(dict.fromkeys(entries)) + + +def _normalize_email(value: Optional[str]) -> Optional[str]: + _name, email = parseaddr(str(value or "").strip()) + normalized = str(email or "").strip().lower() + return normalized or None + + +def _compose_from_display(*, from_name: Optional[str], from_email: Optional[str], legacy_display: Optional[str]) -> Optional[str]: + email_address = _normalize_email(from_email) or _normalize_email(legacy_display) + if not email_address: + return None + parsed_name, _parsed_email = parseaddr(str(legacy_display or "").strip()) + display_name = str(from_name or parsed_name or "").strip() + if display_name: + return f"{display_name} <{email_address}>" + return email_address + + +def _response_error_message(response: httpx.Response) -> str: + try: + payload = response.json() + except json.JSONDecodeError: + return response.text.strip() or response.reason_phrase or "email_provider_error" + if isinstance(payload, dict): + return ( + str(payload.get("message") or "").strip() + or str(payload.get("error") or "").strip() + or json.dumps(payload, ensure_ascii=False) + ) + return json.dumps(payload, ensure_ascii=False) + + +@dataclass(frozen=True) +class EmailSenderConfig: + provider: str + mode: str + resend_api_key: Optional[str] + resend_from_email: Optional[str] + resend_from_name: Optional[str] + resend_from_display: Optional[str] + resend_reply_to: Optional[str] + verified_domain_status: str + allowlist: tuple[str, ...] + + @property + def expose_debug_tokens(self) -> bool: + return self.mode != "production" + + def as_status(self) -> Dict[str, Any]: + sender_email = _normalize_email(self.resend_from_email or self.resend_from_display) + configured = bool(self.resend_api_key and self.resend_from_display) + return { + "provider": self.provider, + "mode": self.mode, + "configured": configured if self.provider == "resend" else True, + "from_email": sender_email, + "from_name": self.resend_from_name, + "from_display": self.resend_from_display, + "reply_to": self.resend_reply_to, + "verified_domain_status": self.verified_domain_status, + "external_delivery_enabled": bool( + self.provider == "resend" + and configured + and self.mode == "production" + and self.verified_domain_status == "verified" + ), + "recipient_policy": "external_allowed" if self.mode == "production" else "test_only", + "allowlist": list(self.allowlist), + "debug_token_echo_enabled": self.expose_debug_tokens, + } + + +class EmailDeliveryError(RuntimeError): + def __init__( + self, + *, + reason: str, + provider: str, + retryable: bool = False, + action_hint: Optional[str] = None, + provider_status: Optional[Dict[str, Any]] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> None: + super().__init__(reason) + self.reason = reason + self.provider = provider + self.retryable = retryable + self.action_hint = action_hint + self.provider_status = dict(provider_status or {}) + self.metadata = dict(metadata or {}) + + def detail(self) -> Dict[str, Any]: + return { + "reason": self.reason, + "provider": self.provider, + "retryable": self.retryable, + "action_hint": self.action_hint, + "provider_status": self.provider_status, + "metadata": self.metadata, + } + + +class StubEmailProvider: + provider_id = "stub" + + def configured(self) -> bool: + return True + + def send( + self, + *, + to_email: str, + subject: str, + html: str, + text: Optional[str] = None, + tags: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + return { + "provider": self.provider_id, + "configured": True, + "status": "queued", + "message_id": f"stub_{abs(hash((to_email, subject))) % 10_000_000}", + "to_email": to_email, + "subject": subject, + "tags": dict(tags or {}), + "preview_text": text or "", + } + + +class ResendEmailProvider: + provider_id = "resend" + api_base_url = "https://api.resend.com" + + def __init__( + self, + *, + api_key: Optional[str], + from_display: Optional[str], + reply_to: Optional[str] = None, + provider_status: Optional[Dict[str, Any]] = None, + ) -> None: + self.api_key = str(api_key or "").strip() or None + self.from_display = str(from_display or "").strip() or None + self.reply_to = str(reply_to or "").strip() or None + self._provider_status = dict(provider_status or {}) + + def configured(self) -> bool: + return bool(self.api_key and self.from_display) + + def send( + self, + *, + to_email: str, + subject: str, + html: str, + text: Optional[str] = None, + tags: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + if not self.configured(): + raise EmailDeliveryError( + reason="provider_not_configured", + provider=self.provider_id, + action_hint="configure_resend_sender", + provider_status=self._provider_status, + ) + payload: Dict[str, Any] = { + "from": self.from_display, + "to": [to_email], + "subject": subject, + "html": html, + } + if text: + payload["text"] = text + if self.reply_to: + payload["reply_to"] = self.reply_to + if tags: + payload["tags"] = [{"name": str(key), "value": str(value)} for key, value in dict(tags).items()] + try: + response = httpx.post( + f"{self.api_base_url}/emails", + json=payload, + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + timeout=20.0, + ) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + response = exc.response + message = _response_error_message(response).lower() + reason = "provider_rejected" + action_hint = "retry_delivery_later" + retryable = response.status_code >= 500 or response.status_code == 429 + if "verify a domain" in message or "domain is not verified" in message: + reason = "domain_not_verified" + action_hint = "verify_sender_domain" + retryable = False + elif "testing emails" in message or "test emails" in message: + reason = "test_mode_external_recipient_blocked" + action_hint = "use_test_recipient_or_verified_domain" + retryable = False + raise EmailDeliveryError( + reason=reason, + provider=self.provider_id, + retryable=retryable, + action_hint=action_hint, + provider_status=self._provider_status, + metadata={ + "response_status": response.status_code, + "response_message": _response_error_message(response), + }, + ) from exc + except httpx.RequestError as exc: + raise EmailDeliveryError( + reason="provider_network_error", + provider=self.provider_id, + retryable=True, + action_hint="retry_delivery_later", + provider_status=self._provider_status, + metadata={"request_error": str(exc)}, + ) from exc + body = response.json() + return { + "provider": self.provider_id, + "configured": True, + "status": "queued", + "message_id": body.get("id"), + "to_email": to_email, + "subject": subject, + "tags": dict(tags or {}), + } + + +class EmailService: + def __init__(self) -> None: + self.config = self._load_config() + provider_status = self.config.as_status() + self.stub = StubEmailProvider() + self.resend = ResendEmailProvider( + api_key=self.config.resend_api_key, + from_display=self.config.resend_from_display, + reply_to=self.config.resend_reply_to, + provider_status=provider_status, + ) + + def _load_config(self) -> EmailSenderConfig: + legacy_from = _first_env("NARRATIVEOS_EMAIL_FROM") + resend_from_email = _first_env("RESEND_FROM_EMAIL") + resend_from_name = _first_env("RESEND_FROM_NAME") + resend_from_display = _compose_from_display( + from_name=resend_from_name, + from_email=resend_from_email, + legacy_display=legacy_from, + ) + parsed_name, _parsed_email = parseaddr(str(legacy_from or "").strip()) + provider = str(_first_env("EMAIL_PROVIDER", "NARRATIVEOS_EMAIL_PROVIDER", default="stub") or "stub").strip() or "stub" + return EmailSenderConfig( + provider=provider, + mode=_normalize_mode(_first_env("EMAIL_MODE", default="test")), + resend_api_key=_first_env("RESEND_API_KEY"), + resend_from_email=_normalize_email(resend_from_email or legacy_from), + resend_from_name=str(resend_from_name or parsed_name or "").strip() or None, + resend_from_display=resend_from_display, + resend_reply_to=_first_env("RESEND_REPLY_TO", "NARRATIVEOS_EMAIL_REPLY_TO"), + verified_domain_status=_normalize_domain_status(_first_env("RESEND_VERIFIED_DOMAIN_STATUS")), + allowlist=_split_allowlist(_first_env("EMAIL_SEND_ALLOWLIST")), + ) + + def _provider(self): + return self.resend if self.config.provider == self.resend.provider_id else self.stub + + def _is_test_recipient(self, email_address: str) -> bool: + normalized = _normalize_email(email_address) or "" + if not normalized: + return False + if normalized.endswith("@resend.dev"): + return True + if normalized in self.config.allowlist: + return True + domain_entry = f"@{normalized.split('@', 1)[1]}" + return domain_entry in self.config.allowlist + + def expose_debug_tokens(self) -> bool: + return self.config.expose_debug_tokens + + def provider_status(self) -> Dict[str, Any]: + return self.config.as_status() + + def auth_preflight(self, *, to_email: str, flow_type: str) -> Dict[str, Any]: + normalized_recipient = _normalize_email(to_email) + if not normalized_recipient: + raise EmailDeliveryError( + reason="invalid_recipient_email", + provider=self.config.provider, + action_hint="enter_valid_email_address", + provider_status=self.provider_status(), + ) + status = self.provider_status() + if self.config.mode == "test": + if not self._is_test_recipient(normalized_recipient): + raise EmailDeliveryError( + reason="test_mode_external_recipient_blocked", + provider=self.config.provider, + action_hint="use_test_recipient_or_verified_domain", + provider_status=status, + metadata={"flow_type": flow_type, "recipient_email": normalized_recipient}, + ) + if self.config.provider == self.resend.provider_id and not self.resend.configured(): + raise EmailDeliveryError( + reason="provider_not_configured", + provider=self.config.provider, + action_hint="configure_resend_sender", + provider_status=status, + metadata={"flow_type": flow_type}, + ) + return { + "recipient_email": normalized_recipient, + "status": "test_ready", + "provider_status": status, + } + if self.config.provider != self.resend.provider_id or not self.resend.configured(): + raise EmailDeliveryError( + reason="provider_not_configured", + provider=self.config.provider, + action_hint="configure_resend_sender", + provider_status=status, + metadata={"flow_type": flow_type}, + ) + if self.config.verified_domain_status != "verified": + raise EmailDeliveryError( + reason="domain_not_verified", + provider=self.config.provider, + action_hint="verify_sender_domain", + provider_status=status, + metadata={"flow_type": flow_type}, + ) + return { + "recipient_email": normalized_recipient, + "status": "production_ready", + "provider_status": status, + } + + def send_verification_email( + self, + *, + to_email: str, + display_name: Optional[str], + verify_token: str, + app_base_url: str, + ) -> Dict[str, Any]: + normalized = self.auth_preflight(to_email=to_email, flow_type="email_verification")["recipient_email"] + safe_name = display_name or normalized + verify_url = f"{app_base_url.rstrip('/')}/verify-email?token={verify_token}" + subject = "Verify your NarrativeOS account" + html = ( + f"

Hello {safe_name},

" + "

Please verify your NarrativeOS account before using paid features.

" + f"

Verify email

" + f"

If the button does not work, use this token: {verify_token}

" + ) + text = ( + f"Hello {safe_name},\n\n" + "Please verify your NarrativeOS account before using paid features.\n" + f"Verify URL: {verify_url}\n" + f"Verification token: {verify_token}\n" + ) + delivery = self._provider().send( + to_email=normalized, + subject=subject, + html=html, + text=text, + tags={"flow": "email_verification"}, + ) + return { + **delivery, + "mode": self.config.mode, + "from_email": self.provider_status().get("from_email"), + } + + def send_password_reset_email( + self, + *, + to_email: str, + display_name: Optional[str], + reset_token: str, + app_base_url: str, + ) -> Dict[str, Any]: + normalized = self.auth_preflight(to_email=to_email, flow_type="password_reset")["recipient_email"] + safe_name = display_name or normalized + reset_url = f"{app_base_url.rstrip('/')}/reset-password?token={reset_token}" + subject = "Reset your NarrativeOS password" + html = ( + f"

Hello {safe_name},

" + "

Use the link below to reset your NarrativeOS password.

" + f"

Reset password

" + f"

If the button does not work, use this token: {reset_token}

" + ) + text = ( + f"Hello {safe_name},\n\n" + "Use the link below to reset your NarrativeOS password.\n" + f"Reset URL: {reset_url}\n" + f"Reset token: {reset_token}\n" + ) + delivery = self._provider().send( + to_email=normalized, + subject=subject, + html=html, + text=text, + tags={"flow": "password_reset"}, + ) + return { + **delivery, + "mode": self.config.mode, + "from_email": self.provider_status().get("from_email"), + } + + def send_email_change_email( + self, + *, + to_email: str, + display_name: Optional[str], + email_change_token: str, + app_base_url: str, + ) -> Dict[str, Any]: + normalized = self.auth_preflight(to_email=to_email, flow_type="email_change")["recipient_email"] + safe_name = display_name or normalized + base_url = app_base_url.rstrip("/") + if base_url.endswith("/app"): + base_url = base_url[:-4] + confirm_url = f"{base_url}/settings/account/email-change/confirm?token={email_change_token}" + subject = "Confirm your new NarrativeOS email" + html = ( + f"

Hello {safe_name},

" + "

Use the link below to confirm this email address for your NarrativeOS account.

" + f"

Confirm new email

" + f"

If the button does not work, use this token: {email_change_token}

" + ) + text = ( + f"Hello {safe_name},\n\n" + "Use the link below to confirm this email address for your NarrativeOS account.\n" + f"Confirm URL: {confirm_url}\n" + f"Confirmation token: {email_change_token}\n" + ) + delivery = self._provider().send( + to_email=normalized, + subject=subject, + html=html, + text=text, + tags={"flow": "email_change"}, + ) + return { + **delivery, + "mode": self.config.mode, + "from_email": self.provider_status().get("from_email"), + } diff --git a/src/narrativeos/services/go_live_day_runner.py b/src/narrativeos/services/go_live_day_runner.py new file mode 100644 index 0000000..bf738d1 --- /dev/null +++ b/src/narrativeos/services/go_live_day_runner.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import csv +import json +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from .customer_success_reporting import CustomerSuccessReportingService +from .launch_command_center import LaunchCommandCenterService +from .production_preflight import ProductionPreflightService +from .wave_activation_controller import WaveActivationControllerService +from ..persistence.repositories import SQLAlchemyPlatformRepository + + +ROOT = Path(__file__).resolve().parents[3] + +CHECKPOINT_KEYS = [ + "pre_activation_signoff_check", + "pre_activation_preflight_check", + "pre_activation_acceptance_check", + "post_activation_t_plus_5", + "post_activation_t_plus_15", + "post_activation_t_plus_60", +] + + +class GoLiveDayRunnerService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + wave_activation_controller_service: WaveActivationControllerService, + production_preflight_service: ProductionPreflightService, + launch_command_center_service: LaunchCommandCenterService, + customer_success_reporting_service: CustomerSuccessReportingService, + base_dir: Optional[Path] = None, + ) -> None: + self.repository = repository + self.wave_activation = wave_activation_controller_service + self.production_preflight = production_preflight_service + self.launch_command_center = launch_command_center_service + self.customer_success = customer_success_reporting_service + self.base_dir = Path(base_dir or ROOT) + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _artifacts_root(self) -> Path: + return self.base_dir / "artifacts" + + def _write_text(self, path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content.rstrip() + "\n", encoding="utf-8") + + def _write_json(self, 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 _write_csv(self, path: Path, *, rows: List[Dict[str, Any]], fieldnames: List[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as fh: + writer = csv.DictWriter(fh, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow({key: row.get(key) for key in fieldnames}) + + def _record_checkpoint( + self, + *, + run_id: str, + checkpoint_key: str, + status: str, + summary: str, + evidence_ref: str, + rollback_recommendation: str, + payload: Dict[str, Any], + ) -> Dict[str, Any]: + return self.repository.save_go_live_day_checkpoint( + { + "go_live_day_run_id": run_id, + "checkpoint_key": checkpoint_key, + "status": status, + "summary": summary, + "evidence_ref": evidence_ref, + "rollback_recommendation": rollback_recommendation, + "checkpoint_payload": payload, + } + ) + + def run( + self, + *, + actor_id: str, + actor_role: str, + launch_wave: str = "wave_1", + signoff_id: Optional[str] = None, + account_id: Optional[str] = None, + ) -> Dict[str, Any]: + current = self.wave_activation.evaluate( + launch_wave=launch_wave, + actor_id=actor_id, + actor_role=actor_role, + ) + account = account_id or next(iter(current.get("launch_account_ids") or []), None) + run = self.repository.save_go_live_day_run( + { + "signoff_id": signoff_id or current.get("current_wave_status", {}).get("signoff_id"), + "launch_wave": launch_wave, + "account_id": account, + "status": "running", + "activation_state_before": current.get("activation_state"), + "report_payload": {"requested_by": actor_id}, + } + ) + checkpoints: List[Dict[str, Any]] = [] + + signoff_ok = str(current.get("signoff_status") or "") == "fully_signed" + checkpoints.append( + self._record_checkpoint( + run_id=run["go_live_day_run_id"], + checkpoint_key="pre_activation_signoff_check", + status="passed" if signoff_ok else "failed", + summary=f"signoff={current.get('signoff_status')}", + evidence_ref=str((current.get("current_wave_status") or {}).get("launch_wave_status_id") or ""), + rollback_recommendation="do_not_activate" if not signoff_ok else "none", + payload=current, + ) + ) + preflight = self.production_preflight.list_runs(launch_wave=launch_wave, limit=1).get("current_run") + preflight_ok = bool(preflight and str(preflight.get("status") or "") == "passed" and str(preflight.get("go_no_go") or "") == "go") + checkpoints.append( + self._record_checkpoint( + run_id=run["go_live_day_run_id"], + checkpoint_key="pre_activation_preflight_check", + status="passed" if preflight_ok else "failed", + summary=f"preflight={preflight.get('status') if preflight else '-'} / {preflight.get('go_no_go') if preflight else '-'}", + evidence_ref=str((preflight or {}).get("preflight_run_id") or ""), + rollback_recommendation="do_not_activate" if not preflight_ok else "none", + payload=preflight or {}, + ) + ) + acceptance_ready = current.get("ready_account_count", 0) >= len(current.get("launch_account_ids") or []) and bool(current.get("launch_account_ids")) + checkpoints.append( + self._record_checkpoint( + run_id=run["go_live_day_run_id"], + checkpoint_key="pre_activation_acceptance_check", + status="passed" if acceptance_ready else "failed", + summary=f"ready_accounts={current.get('ready_account_count')} / launch_accounts={len(current.get('launch_account_ids') or [])}", + evidence_ref=launch_wave, + rollback_recommendation="do_not_activate" if not acceptance_ready else "none", + payload=current, + ) + ) + + post_activation_state = current + if signoff_ok and preflight_ok and acceptance_ready: + if current.get("activation_state") != "active": + armed = self.wave_activation.arm(actor_id=actor_id, actor_role=actor_role, launch_wave=launch_wave) + post_activation_state = armed.get("evaluation") or post_activation_state + + for checkpoint_key, label in [ + ("post_activation_t_plus_5", "T+5"), + ("post_activation_t_plus_15", "T+15"), + ("post_activation_t_plus_60", "T+60"), + ]: + center = self.launch_command_center.command_center(launch_wave=launch_wave) + critical_alerts = [ + item + for panel_key in ("billing_anomaly_panel", "support_urgency_panel", "dispute_anomaly_panel", "dunning_anomaly_panel", "webhook_anomaly_panel") + for item in list((center.get(panel_key) or {}).get("alerts") or []) + if str(item.get("severity") or "") == "critical" + ] + success_report = self.customer_success.list_customer_success(launch_wave=launch_wave, limit=50) + status = "passed" if post_activation_state.get("activation_state") == "active" and not critical_alerts else "failed" + checkpoints.append( + self._record_checkpoint( + run_id=run["go_live_day_run_id"], + checkpoint_key=checkpoint_key, + status=status, + summary=f"{label} active={post_activation_state.get('activation_state')} critical_alerts={len(critical_alerts)}", + evidence_ref=launch_wave, + rollback_recommendation="consider_rollback" if status == "failed" else "none", + payload={"command_center": center, "customer_success": success_report}, + ) + ) + + overall_status = "passed" + if any(checkpoint["checkpoint_key"].startswith("pre_activation") and checkpoint["status"] != "passed" for checkpoint in checkpoints): + overall_status = "failed" + elif any(checkpoint["checkpoint_key"].startswith("post_activation") and checkpoint["status"] != "passed" for checkpoint in checkpoints): + overall_status = "rollback_watch" + updated_run = self.repository.save_go_live_day_run( + { + **run, + "status": overall_status, + "activation_state_before": current.get("activation_state"), + "activation_state_after": post_activation_state.get("activation_state"), + "report_payload": { + "checkpoint_count": len(checkpoints), + "account_id": account, + }, + } + ) + report_refs = self._write_report(run=updated_run, checkpoints=checkpoints) + return { + "go_live_day_run": updated_run, + "checkpoints": checkpoints, + "report_refs": report_refs, + } + + def _write_report(self, *, run: Dict[str, Any], checkpoints: List[Dict[str, Any]]) -> Dict[str, Any]: + bundle_dir = self._artifacts_root() / "go_live_day_runs" / run["go_live_day_run_id"] + bundle_dir.mkdir(parents=True, exist_ok=True) + self._write_json(bundle_dir / "summary.json", {"go_live_day_run": run, "checkpoints": checkpoints}) + report = [ + "# Go-Live Day Report", + "", + f"- go_live_day_run_id: {run['go_live_day_run_id']}", + f"- launch_wave: {run['launch_wave']}", + f"- status: {run['status']}", + f"- activation_before: {run.get('activation_state_before') or '-'}", + f"- activation_after: {run.get('activation_state_after') or '-'}", + "", + "## Checkpoints", + ] + report.extend([f"- {item['checkpoint_key']}: {item['status']} · {item['summary']}" for item in checkpoints]) + self._write_text(bundle_dir / "day_0_report.md", "\n".join(report)) + self._write_csv( + bundle_dir / "checkpoint_log.csv", + rows=checkpoints, + fieldnames=["go_live_day_checkpoint_id", "checkpoint_key", "status", "summary", "evidence_ref", "rollback_recommendation", "created_at"], + ) + latest_dir = self._artifacts_root() / "go_live_day_runs" / "latest" + if latest_dir.exists(): + shutil.rmtree(latest_dir) + shutil.copytree(bundle_dir, latest_dir) + return { + "artifact_dir": str(bundle_dir), + "summary_json": str(bundle_dir / "summary.json"), + "day_0_report_md": str(bundle_dir / "day_0_report.md"), + "checkpoint_log_csv": str(bundle_dir / "checkpoint_log.csv"), + } + + def detail(self, *, run_id: str) -> Dict[str, Any]: + run = self.repository.get_go_live_day_run(run_id) + checkpoints = self.repository.list_go_live_day_checkpoints(go_live_day_run_id=run_id, limit=100) + artifact_dir = self._artifacts_root() / "go_live_day_runs" / run_id + return { + "go_live_day_run": run, + "checkpoints": checkpoints, + "report_refs": { + "artifact_dir": str(artifact_dir), + "summary_json": str(artifact_dir / "summary.json"), + "day_0_report_md": str(artifact_dir / "day_0_report.md"), + "checkpoint_log_csv": str(artifact_dir / "checkpoint_log.csv"), + }, + } + + def summary(self, *, launch_wave: Optional[str] = None) -> Dict[str, Any]: + runs = self.repository.list_go_live_day_runs(launch_wave=launch_wave, limit=50) + current = runs[0] if runs else None + return { + "runs": runs, + "current_run": current, + "summary": { + "run_count": len(runs), + "latest_run_id": (current or {}).get("go_live_day_run_id"), + "status_counts": {key: len([item for item in runs if str(item.get("status") or "") == key]) for key in {"passed", "failed", "rollback_watch", "running"}}, + }, + } diff --git a/src/narrativeos/services/governance.py b/src/narrativeos/services/governance.py index 2c4f8c9..6c75475 100644 --- a/src/narrativeos/services/governance.py +++ b/src/narrativeos/services/governance.py @@ -10,6 +10,7 @@ if TYPE_CHECKING: from .billing import BillingService + from .commercial_audit import CommercialAuditService def parse_governance_notes(value: Any) -> Dict[str, Any]: @@ -29,6 +30,38 @@ class GovernanceService: VALID_TARGET_TYPES = {"account", "world_version", "session", "entitlement"} VALID_STATUSES = {"open", "in_review", "escalated", "resolved", "dismissed"} VALID_RESTRICTION_TYPES = {"reader_access_block", "author_access_block", "checkout_block", "account_hold"} + VALID_OWNER_ROLES = {"reviewer", "ops", "admin"} + BULK_ACTIONS = {"assignOwner", "updateStatus", "updateDueAt", "addPolicyLabels", "removePolicyLabels", "applyRestriction"} + CAPACITY_WINDOW_DAYS = 14 + CAPACITY_BASELINE_VERSION = "governance_capacity_v1" + CAPACITY_OVERRIDE_CONFIG_TYPE = "governance_capacity_override" + ROLE_CAPACITY_BASELINE = { + "reviewer": { + "capacityUnitsPerDay": 12.0, + "criticalCaseLimit": 2, + "activeRestrictionLimit": 3, + "slaHours": 24, + "roleMultiplier": 1.0, + "enabled": True, + }, + "ops": { + "capacityUnitsPerDay": 14.0, + "criticalCaseLimit": 2, + "activeRestrictionLimit": 3, + "slaHours": 24, + "roleMultiplier": 1.15, + "enabled": True, + }, + "admin": { + "capacityUnitsPerDay": 8.0, + "criticalCaseLimit": 2, + "activeRestrictionLimit": 3, + "slaHours": 24, + "roleMultiplier": 0.75, + "enabled": True, + }, + } + OWNER_CAPACITY_OVERRIDES: Dict[str, Dict[str, Any]] = {} STATUS_TRANSITIONS = { "open": {"in_review", "escalated", "dismissed"}, "in_review": {"escalated", "resolved", "dismissed"}, @@ -42,9 +75,11 @@ def __init__( repository: SQLAlchemyPlatformRepository, *, billing_service: Optional["BillingService"] = None, + audit_service: Optional["CommercialAuditService"] = None, ) -> None: self.repository = repository self.billing = billing_service + self.audit = audit_service def _parse_datetime(self, value: Optional[str]) -> Optional[datetime]: if not value: @@ -167,13 +202,936 @@ def _validate_transition(self, current_status: str, next_status: str) -> None: def _owner_for_case(self, case: Dict[str, Any]) -> Optional[str]: return case.get("owner_id") or case.get("reviewer_id") + def owner_roster(self, *, limit: int = 50) -> List[Dict[str, Any]]: + return [ + { + "actor_id": item.get("actor_id"), + "display_name": item.get("display_name") or item.get("actor_id"), + "actor_role": item.get("actor_role"), + "account_id": item.get("account_id"), + "status": item.get("status"), + } + for item in self.repository.list_auth_identities( + actor_roles=sorted(self.VALID_OWNER_ROLES), + status="active", + limit=limit, + ) + ] + + def _validate_assignable_owner(self, owner_id: str) -> Dict[str, Any]: + try: + identity = self.repository.get_auth_identity(str(owner_id or "").strip()) + except KeyError as exc: + raise ValueError("governance_owner_invalid") from exc + if str(identity.get("status") or "") != "active": + raise ValueError("governance_owner_invalid") + if str(identity.get("actor_role") or "") not in self.VALID_OWNER_ROLES: + raise ValueError("governance_owner_invalid") + return identity + + def _validation_result( + self, + *, + target_type: str, + target_id: str, + account_id: Optional[str], + world_version_id: Optional[str] = None, + session_id: Optional[str] = None, + entitlement_id: Optional[str] = None, + warnings: Optional[List[str]] = None, + snapshot: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + normalized_warnings = [str(item) for item in list(warnings or []) if str(item).strip()] + return { + "status": "valid_with_warnings" if normalized_warnings else "valid", + "target_type": target_type, + "target_id": target_id, + "account_id": account_id, + "world_version_id": world_version_id, + "session_id": session_id, + "entitlement_id": entitlement_id, + "validation_warnings": normalized_warnings, + "validated_at": utcnow_iso(), + "target_snapshot": dict(snapshot or {}), + } + + def _invalid_target_validation( + self, + *, + target_type: str, + target_id: Optional[str], + account_id: Optional[str], + code: str, + ) -> Dict[str, Any]: + return { + "status": "invalid", + "code": code, + "target_type": target_type, + "target_id": target_id, + "account_id": account_id, + "validation_warnings": [], + "validated_at": utcnow_iso(), + "target_snapshot": { + "id": target_id, + "target_type": target_type, + "account_id": account_id, + }, + } + + def _validate_target( + self, + *, + target_type: str, + target_id: Optional[str], + account_id: Optional[str], + world_version_id: Optional[str] = None, + session_id: Optional[str] = None, + entitlement_id: Optional[str] = None, + ) -> Dict[str, Any]: + resolved_target_type = str(target_type or "").strip() or "account" + if resolved_target_type not in self.VALID_TARGET_TYPES: + raise ValueError("invalid_target_type") + + resolved_account_id = str(account_id or "").strip() or None + resolved_target_id = str(target_id or "").strip() or None + if resolved_target_type == "account": + resolved_target_id = resolved_target_id or resolved_account_id + resolved_account_id = resolved_account_id or resolved_target_id + if not resolved_target_id: + raise ValueError("governance_case_target_required") + if resolved_account_id and resolved_target_id != resolved_account_id: + raise ValueError("governance_target_account_scope_mismatch") + return self._validation_result( + target_type=resolved_target_type, + target_id=resolved_target_id, + account_id=resolved_account_id, + snapshot={ + "id": resolved_target_id, + "label": resolved_target_id, + "status": "active", + "account_id": resolved_account_id, + "target_type": resolved_target_type, + "validation_warnings": [], + }, + ) + + if not resolved_target_id: + raise ValueError("governance_case_target_required") + + if resolved_target_type == "world_version": + if world_version_id and str(world_version_id).strip() != resolved_target_id: + raise ValueError("governance_target_type_mismatch") + try: + version = self.repository.get_world_version(resolved_target_id) + except KeyError as exc: + raise ValueError("governance_target_not_found") from exc + author_id = str(version.author_id or "").strip() or None + if resolved_account_id and author_id and author_id != resolved_account_id: + raise ValueError("governance_target_account_scope_mismatch") + warnings: List[str] = [] + if str(version.status or "") not in {"published", "submitted"}: + warnings.append("world_version_not_published") + return self._validation_result( + target_type=resolved_target_type, + target_id=resolved_target_id, + account_id=resolved_account_id or author_id, + world_version_id=version.world_version_id, + warnings=warnings, + snapshot={ + "id": version.world_version_id, + "label": (version.worldpack_json.get("title") or version.world_id or version.world_version_id), + "status": version.status, + "account_id": author_id, + "target_type": resolved_target_type, + "world_id": version.world_id, + "world_version_id": version.world_version_id, + "risk_rating": version.risk_rating, + "validation_warnings": warnings, + }, + ) + + if resolved_target_type == "session": + if session_id and str(session_id).strip() != resolved_target_id: + raise ValueError("governance_target_type_mismatch") + try: + session_record = self.repository.get_session(resolved_target_id) + except KeyError as exc: + raise ValueError("governance_target_not_found") from exc + session_owner = str( + session_record.metadata.get("reader_id") + or session_record.player_profile.get("reader_id") + or "" + ).strip() or None + if resolved_account_id and session_owner and session_owner != resolved_account_id: + raise ValueError("governance_target_account_scope_mismatch") + entitlements_snapshot = dict(session_record.metadata.get("entitlements_snapshot") or {}) + warnings = [] + if str(entitlements_snapshot.get("status") or "").strip() == "blocked": + warnings.append("session_access_blocked") + return self._validation_result( + target_type=resolved_target_type, + target_id=resolved_target_id, + account_id=resolved_account_id or session_owner, + session_id=session_record.session_id, + world_version_id=str(session_record.metadata.get("world_version_id") or "").strip() or None, + warnings=warnings, + snapshot={ + "id": session_record.session_id, + "label": session_record.world_id or session_record.session_id, + "status": "active", + "account_id": session_owner, + "target_type": resolved_target_type, + "world_id": session_record.world_id, + "world_version_id": session_record.metadata.get("world_version_id"), + "session_id": session_record.session_id, + "validation_warnings": warnings, + }, + ) + + if entitlement_id and str(entitlement_id).strip() != resolved_target_id: + raise ValueError("governance_target_type_mismatch") + if not self.billing: + raise ValueError("governance_target_not_found") + if not resolved_account_id: + raise ValueError("governance_case_target_required") + entitlements = list( + (self.billing.list_entitlements_for_account(resolved_account_id).get("entitlements") or []) + ) + target_entitlement = next( + ( + item + for item in entitlements + if str(item.get("entitlement_id") or "").strip() == resolved_target_id + ), + None, + ) + if target_entitlement is None: + raise ValueError("governance_target_not_found") + warnings = [] + entitlement_status = str(target_entitlement.get("status") or "").strip() + if entitlement_status not in {"active", "trialing"}: + warnings.append("entitlement_inactive") + expires_at = self._parse_datetime(str(target_entitlement.get("expires_at") or "").strip() or None) + if expires_at and expires_at <= datetime.now(timezone.utc): + warnings.append("entitlement_expired") + return self._validation_result( + target_type=resolved_target_type, + target_id=resolved_target_id, + account_id=resolved_account_id, + entitlement_id=str(target_entitlement.get("entitlement_id") or "").strip() or None, + warnings=warnings, + snapshot={ + "id": target_entitlement.get("entitlement_id"), + "label": f"{str(target_entitlement.get('entitlement_type') or '')}:{str(target_entitlement.get('wallet_type') or target_entitlement.get('tier_id') or target_entitlement.get('world_id') or '')}", + "status": target_entitlement.get("status"), + "account_id": target_entitlement.get("account_id"), + "target_type": resolved_target_type, + "entitlement_id": target_entitlement.get("entitlement_id"), + "entitlement_type": target_entitlement.get("entitlement_type"), + "wallet_type": target_entitlement.get("wallet_type"), + "tier_id": target_entitlement.get("tier_id"), + "world_id": target_entitlement.get("world_id"), + "expires_at": target_entitlement.get("expires_at"), + "validation_warnings": warnings, + }, + ) + + def target_resolver(self, *, account_id: Optional[str], limit: int = 10) -> Dict[str, Any]: + resolved_account_id = str(account_id or "").strip() or None + if not resolved_account_id: + return {"accounts": [], "world_versions": [], "sessions": [], "entitlements": []} + account_detail = self.billing.account_detail(account_id=resolved_account_id, limit=limit) if self.billing else {} + worlds = [] + for item in self.repository.list_world_versions(): + if str(item.get("author_id") or "").strip() != resolved_account_id: + continue + validation = self._validate_target( + target_type="world_version", + target_id=str(item.get("world_version_id") or ""), + account_id=resolved_account_id, + world_version_id=str(item.get("world_version_id") or ""), + ) + worlds.append(validation["target_snapshot"]) + if len(worlds) >= limit: + break + sessions = [] + for item in list(account_detail.get("recent_sessions") or [])[:limit]: + session_id = str(item.get("session_id") or "").strip() + if not session_id: + continue + validation = self._validate_target( + target_type="session", + target_id=session_id, + account_id=resolved_account_id, + session_id=session_id, + ) + sessions.append(validation["target_snapshot"]) + entitlements = [] + if self.billing: + for item in list((self.billing.list_entitlements_for_account(resolved_account_id).get("entitlements") or []))[:limit]: + entitlement_id_value = str(item.get("entitlement_id") or "").strip() + if not entitlement_id_value: + continue + validation = self._validate_target( + target_type="entitlement", + target_id=entitlement_id_value, + account_id=resolved_account_id, + entitlement_id=entitlement_id_value, + ) + entitlements.append(validation["target_snapshot"]) + return { + "accounts": [ + { + "id": resolved_account_id, + "label": resolved_account_id, + "status": "active", + "account_id": resolved_account_id, + "target_type": "account", + "validation_warnings": [], + } + ], + "world_versions": worlds, + "sessions": sessions, + "entitlements": entitlements, + } + + def target_resolver_meta(self, *, account_id: Optional[str]) -> Dict[str, Any]: + return { + "scope_account_id": str(account_id or "").strip() or None, + "strict_scope_enabled": bool(str(account_id or "").strip()), + "validation_mode": "account_scoped_hard", + "supported_target_types": sorted(self.VALID_TARGET_TYPES), + } + + def _clamp(self, minimum: float, maximum: float, value: float) -> float: + return max(minimum, min(maximum, float(value))) + + def _capacity_baseline(self, *, owner_id: str, actor_role: Optional[str]) -> Dict[str, Any]: + baseline = dict(self.ROLE_CAPACITY_BASELINE.get(str(actor_role or "reviewer"), self.ROLE_CAPACITY_BASELINE["reviewer"])) + persisted_override = dict(self._persistent_capacity_override_payload(owner_id=owner_id)) + runtime_override = dict(self.OWNER_CAPACITY_OVERRIDES.get(str(owner_id or "").strip(), {})) + override = {**persisted_override, **runtime_override} + merged = {**baseline, **override} + merged["capacityUnitsPerDay"] = float(merged.get("capacityUnitsPerDay") or baseline["capacityUnitsPerDay"]) + merged["criticalCaseLimit"] = int(merged.get("criticalCaseLimit") or baseline["criticalCaseLimit"]) + merged["activeRestrictionLimit"] = int(merged.get("activeRestrictionLimit") or baseline["activeRestrictionLimit"]) + merged["slaHours"] = int(merged.get("slaHours") or baseline["slaHours"]) + merged["roleMultiplier"] = float(merged.get("roleMultiplier") or baseline["roleMultiplier"]) + merged["enabled"] = bool(merged.get("enabled", True)) + return merged + + def _persistent_capacity_override_map(self, *, limit: int = 200) -> Dict[str, Dict[str, Any]]: + rows = self.repository.list_ops_configs( + config_type=self.CAPACITY_OVERRIDE_CONFIG_TYPE, + limit=limit, + ) + overrides: Dict[str, Dict[str, Any]] = {} + for row in rows: + payload = dict(row.get("config_payload") or {}) + owner_id = str(row.get("scope_key") or payload.get("owner_id") or "").strip() + if not owner_id or owner_id in overrides: + continue + overrides[owner_id] = { + "opsConfigId": row.get("ops_config_id"), + "mode": row.get("status"), + "updatedAt": row.get("updated_at"), + "payload": payload, + } + return overrides + + def _persistent_capacity_override_payload(self, *, owner_id: str) -> Dict[str, Any]: + record = self._persistent_capacity_override_map(limit=500).get(str(owner_id or "").strip(), {}) + if str(record.get("mode") or "") != "active": + return {} + return dict(record.get("payload") or {}) + + def capacity_admin_surface(self) -> Dict[str, Any]: + overrides = self._persistent_capacity_override_map(limit=500) + return { + "can_edit_roles": ["admin"], + "storage": "ops_config", + "configType": self.CAPACITY_OVERRIDE_CONFIG_TYPE, + "baselineConfigVersion": self.CAPACITY_BASELINE_VERSION, + "editableFields": [ + "capacityUnitsPerDay", + "criticalCaseLimit", + "activeRestrictionLimit", + "slaHours", + "roleMultiplier", + "enabled", + ], + "persistedOverrides": [ + { + "ownerId": owner_id, + "opsConfigId": record.get("opsConfigId"), + "mode": record.get("mode"), + "updatedAt": record.get("updatedAt"), + "override": dict(record.get("payload") or {}), + } + for owner_id, record in sorted(overrides.items()) + ], + } + + def update_capacity_override( + self, + owner_id: str, + *, + capacity_units_per_day: Optional[float] = None, + critical_case_limit: Optional[int] = None, + active_restriction_limit: Optional[int] = None, + sla_hours: Optional[int] = None, + role_multiplier: Optional[float] = None, + enabled: Optional[bool] = None, + clear_override: bool = False, + reviewer_id: Optional[str] = None, + actor_role: Optional[str] = None, + note: Optional[str] = None, + source_surface: str = "ops_api", + ) -> Dict[str, Any]: + validated_owner = self._validate_assignable_owner(owner_id) + normalized_owner_id = str(validated_owner.get("actor_id") or owner_id).strip() + if not clear_override and all( + value is None + for value in [ + capacity_units_per_day, + critical_case_limit, + active_restriction_limit, + sla_hours, + role_multiplier, + enabled, + ] + ): + raise ValueError("governance_capacity_override_empty") + if capacity_units_per_day is not None and float(capacity_units_per_day) <= 0: + raise ValueError("invalid_capacity_units_per_day") + if critical_case_limit is not None and int(critical_case_limit) < 0: + raise ValueError("invalid_critical_case_limit") + if active_restriction_limit is not None and int(active_restriction_limit) < 0: + raise ValueError("invalid_active_restriction_limit") + if sla_hours is not None and int(sla_hours) <= 0: + raise ValueError("invalid_sla_hours") + if role_multiplier is not None and float(role_multiplier) <= 0: + raise ValueError("invalid_role_multiplier") + next_payload: Dict[str, Any] = { + "owner_id": normalized_owner_id, + "owner_role": validated_owner.get("actor_role"), + "updated_by": reviewer_id, + "note": note, + } + if not clear_override: + if capacity_units_per_day is not None: + next_payload["capacityUnitsPerDay"] = float(capacity_units_per_day) + if critical_case_limit is not None: + next_payload["criticalCaseLimit"] = int(critical_case_limit) + if active_restriction_limit is not None: + next_payload["activeRestrictionLimit"] = int(active_restriction_limit) + if sla_hours is not None: + next_payload["slaHours"] = int(sla_hours) + if role_multiplier is not None: + next_payload["roleMultiplier"] = float(role_multiplier) + if enabled is not None: + next_payload["enabled"] = bool(enabled) + persisted = self.repository.save_ops_config( + { + "ops_config_id": f"governance_capacity_override::{normalized_owner_id}", + "config_type": self.CAPACITY_OVERRIDE_CONFIG_TYPE, + "scope_key": normalized_owner_id, + "status": "disabled" if clear_override else "active", + "config_payload": next_payload, + } + ) + if self.audit is not None: + self.audit.record_audit_log( + actor_id=str(reviewer_id or "ops_unknown"), + actor_role=str(actor_role or "admin"), + account_id=None, + object_type="governance_capacity_override", + object_id=normalized_owner_id, + action_type="governance_capacity_override_updated", + source_surface=source_surface, + customer_visible_payload={ + "owner_id": normalized_owner_id, + "status": "disabled" if clear_override else "active", + }, + internal_payload={ + "owner_id": normalized_owner_id, + "owner_role": validated_owner.get("actor_role"), + "clear_override": clear_override, + "ops_config": persisted, + "override_payload": next_payload, + }, + ) + return { + "ownerId": normalized_owner_id, + "actorRole": validated_owner.get("actor_role"), + "mode": persisted.get("status"), + "updatedAt": persisted.get("updated_at"), + "override": next_payload, + } + + def _observed_capacity_overlay( + self, + *, + owner_id: str, + cases_by_id: Dict[str, Dict[str, Any]], + baseline: Dict[str, Any], + ) -> Dict[str, Any]: + window_start = datetime.now(timezone.utc) - timedelta(days=self.CAPACITY_WINDOW_DAYS) + audit_logs = self.repository.list_audit_logs( + actor_id=owner_id, + object_type="governance_case", + action_type="governance_case_status_changed", + limit=1000, + ) + resolution_hours: List[float] = [] + overdue_resolved = 0 + resolved_count = 0 + for entry in audit_logs: + occurred_at = self._parse_datetime(str(entry.get("created_at") or "").strip() or None) + if occurred_at is None or occurred_at < window_start: + continue + internal_payload = dict(entry.get("internal_payload_json") or {}) + if str(internal_payload.get("next_status") or "") not in {"resolved", "dismissed"}: + continue + resolved_count += 1 + case = cases_by_id.get(str(entry.get("object_id") or "")) + if not case: + continue + created_at = self._parse_datetime(str(case.get("created_at") or "").strip() or None) + if created_at is not None: + resolution_hours.append(max(0.0, (occurred_at - created_at).total_seconds() / 3600.0)) + due_at = self._parse_datetime(str(case.get("due_at") or "").strip() or None) + if due_at is not None and occurred_at > due_at: + overdue_resolved += 1 + median_resolution_hours = None + if resolution_hours: + ordered = sorted(resolution_hours) + middle = len(ordered) // 2 + if len(ordered) % 2: + median_resolution_hours = ordered[middle] + else: + median_resolution_hours = (ordered[middle - 1] + ordered[middle]) / 2 + overdue_resolved_ratio = (overdue_resolved / resolved_count) if resolved_count else 0.0 + throughput_factor = self._clamp( + 0.85, + 1.15, + resolved_count / max(1.0, float(baseline["capacityUnitsPerDay"]) * float(self.CAPACITY_WINDOW_DAYS)), + ) + if median_resolution_hours is None: + sla_factor = 1.0 + else: + sla_factor = 0.85 if median_resolution_hours > int(baseline["slaHours"]) else 1.05 + overdue_factor = 0.85 if overdue_resolved_ratio > 0.20 else 1.0 + return { + "windowDays": self.CAPACITY_WINDOW_DAYS, + "resolvedCount14d": resolved_count, + "medianResolutionHours14d": round(median_resolution_hours, 2) if median_resolution_hours is not None else None, + "overdueResolvedRatio14d": round(overdue_resolved_ratio, 4), + "throughputFactor": round(throughput_factor, 4), + "slaFactor": round(sla_factor, 4), + "overdueFactor": round(overdue_factor, 4), + } + + def _effective_capacity_units(self, *, baseline: Dict[str, Any], overlay: Dict[str, Any]) -> float: + return round( + float(baseline["capacityUnitsPerDay"]) + * float(baseline["roleMultiplier"]) + * float(overlay["throughputFactor"]) + * float(overlay["slaFactor"]) + * float(overlay["overdueFactor"]), + 2, + ) + + def _calibrated_load_score( + self, + *, + raw_load_units: float, + effective_capacity_units: float, + critical_count: int, + active_restriction_count: int, + baseline: Dict[str, Any], + ) -> float: + score = (float(raw_load_units) / max(1.0, float(effective_capacity_units))) * 10.0 + score += max(0, int(critical_count) - int(baseline["criticalCaseLimit"])) * 2.0 + score += max(0, int(active_restriction_count) - int(baseline["activeRestrictionLimit"])) * 1.5 + return round(score, 2) + + def _severity_weight(self, severity: Optional[str]) -> int: + return { + "critical": 4, + "high": 3, + "medium": 2, + "low": 1, + }.get(str(severity or "medium"), 1) + + def _is_actionable_case(self, case: Dict[str, Any]) -> bool: + return str(case.get("status") or "") in {"open", "in_review", "escalated"} + + def _is_due_within_24h(self, case: Dict[str, Any]) -> bool: + due_at = self._parse_datetime(str(case.get("due_at") or "").strip() or None) + if due_at is None: + return False + now = datetime.now(timezone.utc) + return now <= due_at <= (now + timedelta(hours=24)) + + def _case_load_units(self, case: Dict[str, Any]) -> int: + units = self._severity_weight(str(case.get("severity") or "medium")) + if str(case.get("status") or "") == "escalated": + units += 2 + if bool((case.get("workflow_summary") or {}).get("is_overdue")): + units += 3 + if bool((case.get("restriction") or {}).get("status") == "active"): + units += 2 + if self._is_due_within_24h(case): + units += 1 + return units + + def _queue_case_row(self, case: Dict[str, Any]) -> Dict[str, Any]: + workflow_summary = dict(case.get("workflow_summary") or {}) + restriction = dict(case.get("restriction") or {}) + return { + "case_id": case.get("case_id"), + "account_id": case.get("account_id"), + "summary": case.get("summary"), + "case_type": case.get("case_type"), + "status": case.get("status"), + "severity": case.get("severity"), + "owner_id": workflow_summary.get("owner_id") or case.get("owner_id"), + "due_at": workflow_summary.get("due_at") or case.get("due_at"), + "is_overdue": bool(workflow_summary.get("is_overdue")), + "target_type": case.get("target_type"), + "target_id": case.get("target_id"), + "support_issue_ids": list(case.get("support_issue_ids") or []), + "has_active_restriction": bool(restriction.get("status") == "active"), + "restriction_type": restriction.get("restriction_type"), + "restriction_status": restriction.get("status"), + "updated_at": case.get("updated_at"), + "target_validation": dict(case.get("target_validation") or {}), + } + + def _bulk_action_catalog(self) -> List[Dict[str, Any]]: + return [ + {"id": "assignOwner", "label": "Assign Owner", "requiresPreview": True}, + {"id": "updateStatus", "label": "Update Status", "requiresPreview": True}, + {"id": "updateDueAt", "label": "Update Due At", "requiresPreview": True}, + {"id": "addPolicyLabels", "label": "Add Policy Labels", "requiresPreview": True}, + {"id": "removePolicyLabels", "label": "Remove Policy Labels", "requiresPreview": True}, + {"id": "applyRestriction", "label": "Apply Restriction", "requiresPreview": True}, + ] + + def _filter_queue_cases( + self, + cases: List[Dict[str, Any]], + *, + status: Optional[str] = None, + owner_id: Optional[str] = None, + case_type: Optional[str] = None, + severity: Optional[str] = None, + target_type: Optional[str] = None, + has_active_restriction: Optional[bool] = None, + overdue_only: bool = False, + unassigned_only: bool = False, + search: Optional[str] = None, + ) -> List[Dict[str, Any]]: + filtered = list(cases) + if status: + status_values = {item.strip() for item in str(status).split(",") if item.strip()} + filtered = [item for item in filtered if str(item.get("status") or "") in status_values] + if owner_id: + filtered = [item for item in filtered if str((item.get("workflow_summary") or {}).get("owner_id") or item.get("owner_id") or "") == owner_id] + if case_type: + filtered = [item for item in filtered if str(item.get("case_type") or "") == case_type] + if severity: + filtered = [item for item in filtered if str(item.get("severity") or "") == severity] + if target_type: + filtered = [item for item in filtered if str(item.get("target_type") or "") == target_type] + if has_active_restriction is not None: + filtered = [item for item in filtered if bool((item.get("restriction") or {}).get("status") == "active") == has_active_restriction] + if overdue_only: + filtered = [item for item in filtered if bool((item.get("workflow_summary") or {}).get("is_overdue"))] + if unassigned_only: + filtered = [item for item in filtered if not str((item.get("workflow_summary") or {}).get("owner_id") or item.get("owner_id") or "").strip()] + if search: + normalized_search = str(search or "").strip().lower() + filtered = [ + item + for item in filtered + if normalized_search in " ".join( + [ + str(item.get("summary") or ""), + str(item.get("target_id") or ""), + str(item.get("account_id") or ""), + " ".join(str(value) for value in list(item.get("support_issue_ids") or [])), + ] + ).lower() + ] + return filtered + + def _owner_workload_rows(self, cases: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + roster = self.owner_roster(limit=200) + roster_map = {str(item.get("actor_id") or ""): item for item in roster} + cases_by_id = {str(item.get("case_id") or ""): item for item in cases if str(item.get("case_id") or "").strip()} + persisted_override_map = self._persistent_capacity_override_map(limit=500) + rows: Dict[str, Dict[str, Any]] = { + str(item.get("actor_id") or ""): { + "owner_id": item.get("actor_id"), + "display_name": item.get("display_name") or item.get("actor_id"), + "actor_role": item.get("actor_role"), + "status": item.get("status"), + "open_count": 0, + "in_review_count": 0, + "escalated_count": 0, + "overdue_count": 0, + "active_restriction_count": 0, + "critical_count": 0, + "due_within_24h_count": 0, + "raw_load_units": 0.0, + "case_ids": [], + } + for item in roster + if str(item.get("actor_id") or "").strip() + } + for case in cases: + owner_id = str((case.get("workflow_summary") or {}).get("owner_id") or case.get("owner_id") or "").strip() + if not owner_id: + continue + row = rows.setdefault( + owner_id, + { + "owner_id": owner_id, + "display_name": (roster_map.get(owner_id) or {}).get("display_name") or owner_id, + "actor_role": (roster_map.get(owner_id) or {}).get("actor_role") or "unknown", + "status": (roster_map.get(owner_id) or {}).get("status") or "unknown", + "open_count": 0, + "in_review_count": 0, + "escalated_count": 0, + "overdue_count": 0, + "active_restriction_count": 0, + "critical_count": 0, + "due_within_24h_count": 0, + "raw_load_units": 0.0, + "case_ids": [], + }, + ) + case_status = str(case.get("status") or "") + if case_status == "open": + row["open_count"] += 1 + if case_status == "in_review": + row["in_review_count"] += 1 + if case_status == "escalated": + row["escalated_count"] += 1 + if bool((case.get("workflow_summary") or {}).get("is_overdue")): + row["overdue_count"] += 1 + if bool((case.get("restriction") or {}).get("status") == "active"): + row["active_restriction_count"] += 1 + if str(case.get("severity") or "") == "critical": + row["critical_count"] += 1 + if self._is_due_within_24h(case): + row["due_within_24h_count"] += 1 + row["raw_load_units"] += self._case_load_units(case) + row["case_ids"].append(case.get("case_id")) + return sorted( + [ + { + "ownerId": item["owner_id"], + "displayName": item["display_name"], + "actorRole": item["actor_role"], + "status": item["status"], + "openCount": item["open_count"], + "inReviewCount": item["in_review_count"], + "escalatedCount": item["escalated_count"], + "overdueCount": item["overdue_count"], + "activeRestrictionCount": item["active_restriction_count"], + "criticalCount": item["critical_count"], + "dueWithin24hCount": item["due_within_24h_count"], + "rawLoadUnits": round(float(item["raw_load_units"]), 2), + **self._capacity_profile_payload( + owner_id=str(item["owner_id"] or ""), + actor_role=str(item["actor_role"] or ""), + raw_load_units=float(item["raw_load_units"]), + critical_count=int(item["critical_count"]), + active_restriction_count=int(item["active_restriction_count"]), + cases_by_id=cases_by_id, + persisted_record=persisted_override_map.get(str(item["owner_id"] or "").strip(), {}), + ), + "caseIds": list(item["case_ids"]), + } + for item in rows.values() + ], + key=lambda item: (-float(item["calibratedLoadScore"]), int(item["overdueCount"]), int(item["criticalCount"]), str(item["ownerId"] or "")), + ) + + def _capacity_profile_payload( + self, + *, + owner_id: str, + actor_role: str, + raw_load_units: float, + critical_count: int, + active_restriction_count: int, + cases_by_id: Dict[str, Dict[str, Any]], + persisted_record: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + resolved_persisted_record = dict(persisted_record or {}) + baseline = self._capacity_baseline(owner_id=owner_id, actor_role=actor_role) + overlay = self._observed_capacity_overlay(owner_id=owner_id, cases_by_id=cases_by_id, baseline=baseline) + effective_capacity_units = self._effective_capacity_units(baseline=baseline, overlay=overlay) + calibrated_load_score = self._calibrated_load_score( + raw_load_units=raw_load_units, + effective_capacity_units=effective_capacity_units, + critical_count=critical_count, + active_restriction_count=active_restriction_count, + baseline=baseline, + ) + return { + "capacityUnitsPerDay": baseline["capacityUnitsPerDay"], + "criticalCaseLimit": baseline["criticalCaseLimit"], + "activeRestrictionLimit": baseline["activeRestrictionLimit"], + "slaHours": baseline["slaHours"], + "roleMultiplier": baseline["roleMultiplier"], + "enabled": baseline["enabled"], + "effectiveCapacityUnits": effective_capacity_units, + "calibratedLoadScore": calibrated_load_score, + "observedOverlay": overlay, + "baselineConfigVersion": self.CAPACITY_BASELINE_VERSION, + "hasPersistentOverride": str(resolved_persisted_record.get("mode") or "") == "active", + "overrideMode": str(resolved_persisted_record.get("mode") or "inherited"), + "overrideUpdatedAt": resolved_persisted_record.get("updatedAt"), + "overrideValues": dict(resolved_persisted_record.get("payload") or {}), + } + + def _predict_calibrated_load_score( + self, + *, + owner_row: Dict[str, Any], + additional_units: float = 0.0, + additional_critical: int = 0, + additional_active_restriction: int = 0, + ) -> float: + return self._calibrated_load_score( + raw_load_units=float(owner_row.get("rawLoadUnits") or 0.0) + float(additional_units), + effective_capacity_units=float(owner_row.get("effectiveCapacityUnits") or 0.0), + critical_count=int(owner_row.get("criticalCount") or 0) + int(additional_critical), + active_restriction_count=int(owner_row.get("activeRestrictionCount") or 0) + int(additional_active_restriction), + baseline={ + "criticalCaseLimit": int(owner_row.get("criticalCaseLimit") or 0), + "activeRestrictionLimit": int(owner_row.get("activeRestrictionLimit") or 0), + }, + ) + + def _build_rebalance_preview(self, cases: List[Dict[str, Any]]) -> Dict[str, Any]: + workload = self._owner_workload_rows(cases) + owner_rows = {str(item.get("ownerId") or ""): item for item in workload if str(item.get("ownerId") or "").strip()} + actionable_cases = [item for item in cases if self._is_actionable_case(item)] + if not actionable_cases or not owner_rows: + return { + "eligibleCaseIds": [], + "skippedCaseIds": [], + "perCaseOutcome": [], + "aggregateDelta": {"eligibleCount": 0, "skippedCount": 0, "ownerDelta": {}}, + "ownerAssignments": {}, + } + per_case_outcome: List[Dict[str, Any]] = [] + owner_assignments: Dict[str, str] = {} + owner_delta: Dict[str, Dict[str, int]] = { + owner_id: {"incoming": 0, "outgoing": 0} + for owner_id in owner_rows.keys() + } + ordered_cases = sorted( + actionable_cases, + key=lambda item: ( + -self._case_load_units(item), + -self._severity_weight(str(item.get("severity") or "medium")), + str(item.get("due_at") or ""), + str(item.get("case_id") or ""), + ), + ) + for case in ordered_cases: + case_id = str(case.get("case_id") or "") + current_owner_id = str((case.get("workflow_summary") or {}).get("owner_id") or case.get("owner_id") or "").strip() + current_owner_row = owner_rows.get(current_owner_id) + additional_units = self._case_load_units(case) + additional_critical = 1 if str(case.get("severity") or "") == "critical" else 0 + additional_active_restriction = 1 if bool((case.get("restriction") or {}).get("status") == "active") else 0 + candidate_rows = [] + for owner_id, owner_row in owner_rows.items(): + if not bool(owner_row.get("enabled")) or owner_id == current_owner_id: + continue + if additional_critical and int(owner_row.get("criticalCount") or 0) >= int(owner_row.get("criticalCaseLimit") or 0): + continue + if additional_active_restriction and int(owner_row.get("activeRestrictionCount") or 0) >= int(owner_row.get("activeRestrictionLimit") or 0): + continue + candidate_rows.append(owner_row) + if not candidate_rows: + continue + candidate_rows.sort( + key=lambda owner_row: ( + self._predict_calibrated_load_score( + owner_row=owner_row, + additional_units=additional_units, + additional_critical=additional_critical, + additional_active_restriction=additional_active_restriction, + ), + int(owner_row.get("overdueCount") or 0), + int(owner_row.get("criticalCount") or 0), + str(owner_row.get("ownerId") or ""), + ) + ) + selected_owner_row = candidate_rows[0] + min_candidate_score = self._predict_calibrated_load_score( + owner_row=selected_owner_row, + additional_units=additional_units, + additional_critical=additional_critical, + additional_active_restriction=additional_active_restriction, + ) + current_owner_score = ( + float(current_owner_row.get("calibratedLoadScore") or 0.0) + if current_owner_row and bool(current_owner_row.get("enabled")) + else float("inf") + ) + should_suggest = (not current_owner_id) or (current_owner_score > (min_candidate_score + 1.0)) + if not should_suggest: + continue + next_owner_id = str(selected_owner_row.get("ownerId") or "") + owner_assignments[case_id] = next_owner_id + if current_owner_id: + owner_delta.setdefault(current_owner_id, {"incoming": 0, "outgoing": 0}) + owner_delta[current_owner_id]["outgoing"] += 1 + owner_delta.setdefault(next_owner_id, {"incoming": 0, "outgoing": 0}) + owner_delta[next_owner_id]["incoming"] += 1 + per_case_outcome.append( + { + "caseId": case_id, + "status": "eligible", + "reason": "rebalance_owner_assignment", + "currentOwnerId": current_owner_id or None, + "nextOwnerId": next_owner_id, + "currentPredictedCalibratedLoadScore": None if current_owner_score == float("inf") else round(current_owner_score, 2), + "nextPredictedCalibratedLoadScore": round(min_candidate_score, 2), + } + ) + eligible_case_ids = [item["caseId"] for item in per_case_outcome] + skipped_case_ids = [str(item.get("case_id") or "") for item in ordered_cases if str(item.get("case_id") or "") not in set(eligible_case_ids)] + return { + "eligibleCaseIds": eligible_case_ids, + "skippedCaseIds": skipped_case_ids, + "perCaseOutcome": per_case_outcome, + "aggregateDelta": { + "eligibleCount": len(eligible_case_ids), + "skippedCount": len(skipped_case_ids), + "ownerDelta": owner_delta, + }, + "ownerAssignments": owner_assignments, + } + def _permission_summary(self, case: Dict[str, Any], *, actor_id: Optional[str], actor_role: Optional[str]) -> Dict[str, Any]: - privileged = actor_role in {None, "reviewer", "ops"} + privileged = actor_role in {None, "reviewer", "ops", "admin"} owner_id = self._owner_for_case(case) can_claim = privileged and bool(actor_id) and case.get("status") in {"open", "escalated"} can_assign = privileged and bool(actor_id) can_add_evidence = privileged and bool(actor_id) can_release_restriction = privileged and bool(actor_id) and bool((case.get("restriction") or {}).get("status") == "active") and (not owner_id or owner_id == actor_id) + can_edit_restriction = privileged and bool(actor_id) and bool((case.get("restriction") or {}).get("status") == "active") and (not owner_id or owner_id == actor_id) can_transition = privileged and bool(actor_id) and (not owner_id or owner_id == actor_id or case.get("status") == "open") return { "actor_id": actor_id, @@ -184,6 +1142,7 @@ def _permission_summary(self, case: Dict[str, Any], *, actor_id: Optional[str], "can_add_evidence": can_add_evidence, "can_transition": can_transition, "can_release_restriction": can_release_restriction, + "can_edit_restriction": can_edit_restriction, } def _workflow_summary(self, case: Dict[str, Any]) -> Dict[str, Any]: @@ -244,6 +1203,37 @@ def _normalize_case_record(self, record: Dict[str, Any]) -> Dict[str, Any]: if not account_id and target_type == "account": account_id = target_id restriction = self._normalize_restriction(payload.get("restriction")) + target_snapshot = dict(payload.get("target_snapshot") or {}) or None + target_validation = dict(payload.get("target_validation") or {}) or None + if not target_validation and target_id: + try: + fallback_validation = self._validate_target( + target_type=target_type, + target_id=str(target_id), + account_id=str(account_id or "").strip() or None, + world_version_id=str(payload.get("world_version_id") or "").strip() or None, + session_id=str(payload.get("session_id") or "").strip() or None, + entitlement_id=str(payload.get("entitlement_id") or "").strip() or None, + ) + target_snapshot = dict(target_snapshot or fallback_validation.get("target_snapshot") or {}) or None + target_validation = { + key: value + for key, value in fallback_validation.items() + if key != "target_snapshot" + } + except ValueError as exc: + fallback_validation = self._invalid_target_validation( + target_type=target_type, + target_id=str(target_id), + account_id=str(account_id or "").strip() or None, + code=str(exc), + ) + target_snapshot = dict(target_snapshot or fallback_validation.get("target_snapshot") or {}) or None + target_validation = { + key: value + for key, value in fallback_validation.items() + if key != "target_snapshot" + } case = { "case_id": record.get("asset_id"), "review_id": record.get("review_id"), @@ -256,7 +1246,7 @@ def _normalize_case_record(self, record: Dict[str, Any]) -> Dict[str, Any]: "target_type": target_type, "target_id": target_id, "account_id": account_id, - "world_id": payload.get("world_id"), + "world_id": payload.get("world_id") or dict(target_validation.get("target_snapshot") or {}).get("world_id"), "world_version_id": payload.get("world_version_id"), "session_id": payload.get("session_id"), "entitlement_id": payload.get("entitlement_id"), @@ -275,6 +1265,8 @@ def _normalize_case_record(self, record: Dict[str, Any]) -> Dict[str, Any]: checklist=payload.get("workflow_checklist"), ), "restriction": restriction, + "target_snapshot": target_snapshot, + "target_validation": target_validation, "status_transitions": transitions, "latest_transition": latest_transition, "reviewer_id": record.get("reviewer_id"), @@ -369,7 +1361,7 @@ def _recommended_prefills(self, *, account_id: str, support_lookup: Dict[str, An "severity": issue.get("severity") or "medium", "summary": issue.get("title") or issue_type, "description": issue.get("summary") or "", - "support_issue_ids": issue.get("issue_id"), + "support_issue_ids": [issue.get("issue_id")] if issue.get("issue_id") else [], }, } ) @@ -466,6 +1458,12 @@ def case_detail( **case, "linked_support_issues": linked_support_issues, "audit_events": audit_events, + "operator_audit_trail": self.repository.list_audit_logs( + object_type="governance_case", + object_id=case_id, + limit=20, + ), + "restriction_history": self.restriction_history(case_id, limit=20).get("entries", []), "detail_summary": { "linked_support_issue_count": len(linked_support_issues), "audit_event_count": len(audit_events), @@ -522,6 +1520,609 @@ def list_restrictions( }, } + def _load_case_record(self, case_id: str) -> Dict[str, Any]: + records = self.repository.list_review_records(asset_type="governance_case", asset_id=case_id) + if not records: + raise KeyError("unknown_governance_case:%s" % case_id) + return records[0] + + def _diff_snapshots(self, previous: Optional[Dict[str, Any]], current: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + diff: Dict[str, Dict[str, Any]] = {} + previous_payload = dict(previous or {}) + current_payload = dict(current or {}) + for key in sorted(set(previous_payload) | set(current_payload)): + if previous_payload.get(key) != current_payload.get(key): + diff[key] = {"before": previous_payload.get(key), "after": current_payload.get(key)} + return diff + + def restriction_history(self, case_id: str, *, limit: int = 20) -> Dict[str, Any]: + _ = self._load_case_record(case_id) + events = [ + item + for item in reversed( + self.repository.list_audit_logs( + object_type="governance_case", + object_id=case_id, + limit=max(limit * 4, 40), + ) + ) + if str(item.get("action_type") or "") in { + "governance_restriction_applied", + "governance_restriction_updated", + "governance_restriction_released", + } + ] + history: List[Dict[str, Any]] = [] + previous_snapshot: Optional[Dict[str, Any]] = None + for entry in events: + raw_payload = dict(entry.get("internal_payload_json") or {}) + action_type = str(entry.get("action_type") or "") + if action_type == "governance_restriction_applied": + snapshot = { + "restriction_id": raw_payload.get("restriction_id"), + "restriction_type": raw_payload.get("restriction_type"), + "reason": raw_payload.get("restriction_reason"), + "expires_at": raw_payload.get("expires_at"), + "status": "active", + "applied_at": entry.get("created_at"), + "applied_by": entry.get("actor_id"), + } + elif action_type == "governance_restriction_updated": + snapshot = dict(raw_payload.get("next_restriction") or {}) + else: + snapshot = dict(raw_payload.get("restriction") or {}) + history.append( + { + "auditLogId": entry.get("audit_log_id"), + "actionType": action_type, + "actorId": entry.get("actor_id"), + "createdAt": entry.get("created_at"), + "snapshot": snapshot, + "diff": self._diff_snapshots(previous_snapshot, snapshot), + "rawPayload": raw_payload, + } + ) + previous_snapshot = dict(snapshot) + return { + "caseId": case_id, + "entries": history[-limit:], + } + + def update_case_due_at( + self, + case_id: str, + *, + due_at: str, + reviewer_id: Optional[str] = None, + actor_role: Optional[str] = None, + source_surface: str = "ops_api", + ) -> Dict[str, Any]: + parsed_due_at = self._parse_datetime(str(due_at or "").strip() or None) + if parsed_due_at is None: + raise ValueError("invalid_due_at") + existing = self._load_case_record(case_id) + notes = parse_governance_notes(existing.get("notes")) + current_case = self._normalize_case_record(existing) + previous_due_at = notes.get("due_at") + notes["due_at"] = parsed_due_at.isoformat() + updated = self.repository.save_review_record( + { + "review_id": existing.get("review_id"), + "asset_type": "governance_case", + "asset_id": case_id, + "status": existing.get("status"), + "reviewer_id": reviewer_id or existing.get("reviewer_id"), + "risk_rating": existing.get("risk_rating"), + "notes": json.dumps(notes, ensure_ascii=False), + } + ) + if self.audit is not None: + self.audit.record_audit_log( + actor_id=str(reviewer_id or existing.get("reviewer_id") or "ops_unknown"), + actor_role=str(actor_role or "reviewer"), + account_id=str(current_case.get("account_id") or "") or None, + object_type="governance_case", + object_id=case_id, + action_type="governance_case_due_at_updated", + source_surface=source_surface, + customer_visible_payload={ + "status": existing.get("status"), + "summary": str(current_case.get("summary") or case_id), + "due_at": parsed_due_at.isoformat(), + }, + internal_payload={ + "case_id": case_id, + "previous_due_at": previous_due_at, + "next_due_at": parsed_due_at.isoformat(), + }, + ) + return self._normalize_case_record(updated) + + def update_case_policy_labels( + self, + case_id: str, + *, + add_labels: Optional[List[str]] = None, + remove_labels: Optional[List[str]] = None, + reviewer_id: Optional[str] = None, + actor_role: Optional[str] = None, + source_surface: str = "ops_api", + ) -> Dict[str, Any]: + normalized_add = [str(item).strip() for item in list(add_labels or []) if str(item).strip()] + normalized_remove = [str(item).strip() for item in list(remove_labels or []) if str(item).strip()] + if not normalized_add and not normalized_remove: + raise ValueError("policy_labels_required") + existing = self._load_case_record(case_id) + notes = parse_governance_notes(existing.get("notes")) + current_case = self._normalize_case_record(existing) + previous_labels = list(notes.get("policy_labels") or []) + next_labels = list(previous_labels) + for label in normalized_add: + if label not in next_labels: + next_labels.append(label) + for label in normalized_remove: + next_labels = [item for item in next_labels if item != label] + notes["policy_labels"] = next_labels + updated = self.repository.save_review_record( + { + "review_id": existing.get("review_id"), + "asset_type": "governance_case", + "asset_id": case_id, + "status": existing.get("status"), + "reviewer_id": reviewer_id or existing.get("reviewer_id"), + "risk_rating": existing.get("risk_rating"), + "notes": json.dumps(notes, ensure_ascii=False), + } + ) + if self.audit is not None: + self.audit.record_audit_log( + actor_id=str(reviewer_id or existing.get("reviewer_id") or "ops_unknown"), + actor_role=str(actor_role or "reviewer"), + account_id=str(current_case.get("account_id") or "") or None, + object_type="governance_case", + object_id=case_id, + action_type="governance_case_policy_labels_updated", + source_surface=source_surface, + customer_visible_payload={ + "status": existing.get("status"), + "summary": str(current_case.get("summary") or case_id), + "policy_labels": next_labels, + }, + internal_payload={ + "case_id": case_id, + "previous_policy_labels": previous_labels, + "next_policy_labels": next_labels, + }, + ) + return self._normalize_case_record(updated) + + def apply_case_restriction( + self, + case_id: str, + *, + restriction_type: str, + reviewer_id: Optional[str] = None, + actor_role: Optional[str] = None, + restriction_reason: Optional[str] = None, + expires_at: Optional[str] = None, + source_surface: str = "ops_api", + ) -> Dict[str, Any]: + normalized_type = str(restriction_type or "").strip() + if normalized_type not in self.VALID_RESTRICTION_TYPES: + raise ValueError("invalid_restriction_type") + parsed_expiry = None + if str(expires_at or "").strip(): + parsed_expiry = self._parse_datetime(str(expires_at or "").strip()) + if parsed_expiry is None or parsed_expiry <= datetime.now(timezone.utc): + raise ValueError("invalid_restriction_expiry") + existing = self._load_case_record(case_id) + notes = parse_governance_notes(existing.get("notes")) + current_case = self._normalize_case_record(existing) + if str(current_case.get("target_type") or "") != "account" or not str(current_case.get("account_id") or "").strip(): + raise ValueError("governance_case_restriction_target_ineligible") + if bool((current_case.get("restriction") or {}).get("status") == "active"): + raise ValueError("governance_restriction_already_active") + if not self._is_actionable_case(current_case): + raise ValueError("governance_case_not_actionable") + owner_id = self._owner_for_case(current_case) + acting_reviewer = str(reviewer_id or existing.get("reviewer_id") or "").strip() or None + if owner_id and acting_reviewer and owner_id != acting_reviewer: + raise PermissionError("governance_case_owner_required") + notes["restriction"] = { + "restriction_id": "restriction_%s" % uuid4().hex[:10], + "restriction_type": normalized_type, + "scope": { + "reader_access_block": "reader", + "author_access_block": "author", + "checkout_block": "checkout", + "account_hold": "account", + }[normalized_type], + "status": "active", + "reason": str(restriction_reason or "").strip() or current_case.get("summary"), + "applied_at": utcnow_iso(), + "applied_by": acting_reviewer, + "expires_at": parsed_expiry.isoformat() if parsed_expiry else None, + "released_at": None, + "released_by": None, + "release_reason": None, + } + policy_labels = list(notes.get("policy_labels") or []) + if normalized_type not in policy_labels: + policy_labels.append(normalized_type) + notes["policy_labels"] = policy_labels + updated = self.repository.save_review_record( + { + "review_id": existing.get("review_id"), + "asset_type": "governance_case", + "asset_id": case_id, + "status": existing.get("status"), + "reviewer_id": acting_reviewer or existing.get("reviewer_id"), + "risk_rating": existing.get("risk_rating"), + "notes": json.dumps(notes, ensure_ascii=False), + } + ) + normalized = self._normalize_case_record(updated) + if self.audit is not None: + restriction = dict(normalized.get("restriction") or {}) + self.audit.record_audit_log( + actor_id=str(acting_reviewer or existing.get("reviewer_id") or "ops_unknown"), + actor_role=str(actor_role or "reviewer"), + account_id=str(current_case.get("account_id") or "") or None, + object_type="governance_case", + object_id=case_id, + action_type="governance_restriction_applied", + source_surface=source_surface, + customer_visible_payload={ + "status": normalized.get("status"), + "summary": str(current_case.get("summary") or case_id), + "restriction_type": restriction.get("restriction_type"), + }, + internal_payload={ + "case_id": case_id, + "restriction_id": restriction.get("restriction_id"), + "account_id": current_case.get("account_id"), + "world_id": current_case.get("world_id"), + "world_version_id": current_case.get("world_version_id"), + "restriction_type": restriction.get("restriction_type"), + "restriction_reason": restriction.get("reason"), + "summary": current_case.get("summary"), + "description": current_case.get("description"), + "expires_at": restriction.get("expires_at"), + "support_issue_ids": list(current_case.get("support_issue_ids") or []), + }, + ) + return normalized + + def _preview_bulk_case_action( + self, + case: Dict[str, Any], + *, + action: str, + payload: Dict[str, Any], + reviewer_id: Optional[str], + actor_role: Optional[str], + ) -> Dict[str, Any]: + owner_id = self._owner_for_case(case) + permission_summary = self._permission_summary(case, actor_id=reviewer_id, actor_role=actor_role) + result: Dict[str, Any] = { + "caseId": case.get("case_id"), + "currentOwnerId": owner_id, + "currentStatus": case.get("status"), + "currentDueAt": case.get("due_at"), + } + try: + if action == "assignOwner": + owner_assignments = dict(payload.get("owner_assignments") or {}) + next_owner_id = str(owner_assignments.get(str(case.get("case_id") or "")) or payload.get("owner_id") or "").strip() + if not next_owner_id: + raise ValueError("owner_required") + self._validate_assignable_owner(next_owner_id) + result.update({"nextOwnerId": next_owner_id, "nextDueAt": payload.get("due_at")}) + elif action == "updateStatus": + next_status = str(payload.get("status") or "").strip() + if not next_status: + raise ValueError("status_required") + self._validate_transition(str(case.get("status") or "open"), next_status) + if next_status in {"resolved", "dismissed"} and not str(payload.get("resolution_notes") or "").strip(): + raise ValueError("resolution_notes_required") + if str(case.get("status") or "") in {"in_review", "escalated"} and next_status in {"escalated", "resolved", "dismissed"} and owner_id and reviewer_id != owner_id: + raise PermissionError("governance_case_owner_required") + result.update({"nextStatus": next_status}) + elif action == "updateDueAt": + parsed_due_at = self._parse_datetime(str(payload.get("due_at") or "").strip() or None) + if parsed_due_at is None: + raise ValueError("invalid_due_at") + result.update({"nextDueAt": parsed_due_at.isoformat()}) + elif action == "addPolicyLabels": + next_labels = [str(item).strip() for item in list(payload.get("policy_labels") or []) if str(item).strip()] + if not next_labels: + raise ValueError("policy_labels_required") + result.update({"nextPolicyLabels": sorted(set(list(case.get("policy_labels") or []) + next_labels))}) + elif action == "removePolicyLabels": + next_labels = [str(item).strip() for item in list(payload.get("policy_labels") or []) if str(item).strip()] + if not next_labels: + raise ValueError("policy_labels_required") + result.update({"nextPolicyLabels": [item for item in list(case.get("policy_labels") or []) if item not in next_labels]}) + elif action == "applyRestriction": + normalized_type = str(payload.get("restriction_type") or "").strip() + if normalized_type not in self.VALID_RESTRICTION_TYPES: + raise ValueError("invalid_restriction_type") + if bool((case.get("restriction") or {}).get("status") == "active"): + raise ValueError("governance_restriction_already_active") + if str(case.get("target_type") or "") != "account" or not str(case.get("account_id") or "").strip(): + raise ValueError("governance_case_restriction_target_ineligible") + if not self._is_actionable_case(case): + raise ValueError("governance_case_not_actionable") + if not permission_summary.get("can_edit_restriction") and owner_id: + raise PermissionError("governance_case_owner_required") + if str(payload.get("expires_at") or "").strip(): + parsed_expiry = self._parse_datetime(str(payload.get("expires_at") or "").strip()) + if parsed_expiry is None or parsed_expiry <= datetime.now(timezone.utc): + raise ValueError("invalid_restriction_expiry") + result.update({"nextRestrictionType": normalized_type}) + else: + raise ValueError("unsupported_bulk_action") + except (ValueError, PermissionError) as exc: + return {**result, "status": "skipped", "reason": str(exc)} + return {**result, "status": "eligible"} + + def owner_workload( + self, + *, + status: Optional[str] = None, + owner_id: Optional[str] = None, + case_type: Optional[str] = None, + severity: Optional[str] = None, + target_type: Optional[str] = None, + has_active_restriction: Optional[bool] = None, + overdue_only: bool = False, + unassigned_only: bool = False, + search: Optional[str] = None, + selected_case_ids: Optional[List[str]] = None, + limit: int = 100, + ) -> Dict[str, Any]: + cases = self.list_cases(limit=max(limit * 5, 500)).get("cases", []) + filtered_cases = self._filter_queue_cases( + cases, + status=status, + owner_id=owner_id, + case_type=case_type, + severity=severity, + target_type=target_type, + has_active_restriction=has_active_restriction, + overdue_only=overdue_only, + unassigned_only=unassigned_only, + search=search, + ) + case_rows = [self._queue_case_row(item) for item in filtered_cases[:limit]] + owner_workload = self._owner_workload_rows(filtered_cases) + rebalance_preview = self._build_rebalance_preview(filtered_cases) + selection_limit = 50 + requested_selected_case_ids = [str(item).strip() for item in list(selected_case_ids or []) if str(item).strip()] + truncated_selected_case_ids = requested_selected_case_ids[:selection_limit] + visible_case_ids = {str(item.get("case_id") or "") for item in case_rows if str(item.get("case_id") or "").strip()} + retained_selected_case_ids = [item for item in truncated_selected_case_ids if item in visible_case_ids] + dropped_case_ids = [item for item in truncated_selected_case_ids if item not in visible_case_ids] + selection_truncated = len(requested_selected_case_ids) > selection_limit + return { + "queueSummary": { + "totalCaseCount": len(filtered_cases), + "openCount": sum(1 for item in filtered_cases if str(item.get("status") or "") == "open"), + "inReviewCount": sum(1 for item in filtered_cases if str(item.get("status") or "") == "in_review"), + "escalatedCount": sum(1 for item in filtered_cases if str(item.get("status") or "") == "escalated"), + "overdueCount": sum(1 for item in filtered_cases if bool((item.get("workflow_summary") or {}).get("is_overdue"))), + "unassignedCount": sum(1 for item in filtered_cases if not str((item.get("workflow_summary") or {}).get("owner_id") or item.get("owner_id") or "").strip()), + "activeRestrictionCount": sum(1 for item in filtered_cases if bool((item.get("restriction") or {}).get("status") == "active")), + "criticalCount": sum(1 for item in filtered_cases if str(item.get("severity") or "") == "critical"), + "rebalanceSuggestionCount": len(list(rebalance_preview.get("eligibleCaseIds") or [])), + }, + "caseRows": case_rows, + "ownerWorkload": owner_workload, + "filters": { + "status": status, + "ownerId": owner_id, + "caseType": case_type, + "severity": severity, + "targetType": target_type, + "hasActiveRestriction": has_active_restriction, + "overdueOnly": overdue_only, + "unassignedOnly": unassigned_only, + "search": search, + "limit": limit, + }, + "selectionState": { + "selectedCaseIds": retained_selected_case_ids, + "droppedCaseIds": dropped_case_ids, + "truncated": selection_truncated, + "maxSelectable": selection_limit, + "selectVisibleSupported": True, + "clearSelectionSupported": True, + }, + "bulkActionCatalog": self._bulk_action_catalog(), + "rebalancePreview": rebalance_preview, + "rebalanceMeta": { + "mode": "balanced_mix", + "recomputedAt": utcnow_iso(), + "sourceFilters": { + "status": status, + "ownerId": owner_id, + "caseType": case_type, + "severity": severity, + "targetType": target_type, + "hasActiveRestriction": has_active_restriction, + "overdueOnly": overdue_only, + "unassignedOnly": unassigned_only, + "search": search, + "limit": limit, + }, + "selectionCount": len(retained_selected_case_ids), + }, + "capacityModel": { + "windowDays": self.CAPACITY_WINDOW_DAYS, + "baselineConfigVersion": self.CAPACITY_BASELINE_VERSION, + "overlayEnabled": True, + "slaStrategy": "balanced_mix", + }, + "capacityAdminSurface": self.capacity_admin_surface(), + } + + def bulk_action_preview( + self, + *, + case_ids: List[str], + action: str, + payload: Dict[str, Any], + reviewer_id: Optional[str], + actor_role: Optional[str] = None, + ) -> Dict[str, Any]: + normalized_action = str(action or "").strip() + if normalized_action not in self.BULK_ACTIONS: + raise ValueError("unsupported_bulk_action") + per_case_outcome: List[Dict[str, Any]] = [] + affected_account_ids: List[str] = [] + for case_id in [str(item).strip() for item in case_ids if str(item).strip()]: + try: + case = self._normalize_case_record(self._load_case_record(case_id)) + except KeyError: + per_case_outcome.append({"caseId": case_id, "status": "skipped", "reason": "unknown_case"}) + continue + affected_account_ids.append(str(case.get("account_id") or "").strip()) + per_case_outcome.append( + self._preview_bulk_case_action( + case, + action=normalized_action, + payload=payload, + reviewer_id=reviewer_id, + actor_role=actor_role, + ) + ) + eligible_case_ids = [str(item.get("caseId") or "") for item in per_case_outcome if str(item.get("status") or "") == "eligible"] + skipped_case_ids = [str(item.get("caseId") or "") for item in per_case_outcome if str(item.get("status") or "") == "skipped"] + owner_delta: Dict[str, Dict[str, int]] = {} + status_delta: Dict[str, Dict[str, int]] = {} + active_restriction_delta = 0 + for item in per_case_outcome: + if str(item.get("status") or "") != "eligible": + continue + current_owner = str(item.get("currentOwnerId") or "").strip() + next_owner = str(item.get("nextOwnerId") or "").strip() + if current_owner != next_owner and next_owner: + if current_owner: + owner_delta.setdefault(current_owner, {"outgoing": 0, "incoming": 0}) + owner_delta[current_owner]["outgoing"] += 1 + owner_delta.setdefault(next_owner, {"outgoing": 0, "incoming": 0}) + owner_delta[next_owner]["incoming"] += 1 + current_status = str(item.get("currentStatus") or "").strip() + next_status = str(item.get("nextStatus") or current_status).strip() + if current_status != next_status and next_status: + status_delta.setdefault(current_status, {"from": 0, "to": 0}) + status_delta.setdefault(next_status, {"from": 0, "to": 0}) + status_delta[current_status]["from"] += 1 + status_delta[next_status]["to"] += 1 + if normalized_action == "applyRestriction": + active_restriction_delta += 1 + return { + "action": normalized_action, + "eligibleCaseIds": eligible_case_ids, + "skippedCaseIds": skipped_case_ids, + "perCaseOutcome": per_case_outcome, + "aggregateDelta": { + "eligibleCount": len(eligible_case_ids), + "skippedCount": len(skipped_case_ids), + "ownerDelta": owner_delta, + "statusDelta": status_delta, + "activeRestrictionDelta": active_restriction_delta, + }, + "affectedAccountIds": sorted({item for item in affected_account_ids if item}), + } + + def bulk_action_execute( + self, + *, + case_ids: List[str], + action: str, + payload: Dict[str, Any], + reviewer_id: Optional[str], + actor_role: Optional[str] = None, + source_surface: str = "ops_api", + ) -> Dict[str, Any]: + preview = self.bulk_action_preview( + case_ids=case_ids, + action=action, + payload=payload, + reviewer_id=reviewer_id, + actor_role=actor_role, + ) + executed: List[Dict[str, Any]] = [] + normalized_action = str(action or "").strip() + for item in list(preview.get("perCaseOutcome") or []): + if str(item.get("status") or "") != "eligible": + executed.append({**item, "status": "skipped"}) + continue + case_id = str(item.get("caseId") or "").strip() + if normalized_action == "assignOwner": + owner_assignments = dict(payload.get("owner_assignments") or {}) + owner_id_value = str(owner_assignments.get(case_id) or payload.get("owner_id") or "").strip() + updated = self.assign_case( + case_id, + owner_id=owner_id_value, + reviewer_id=reviewer_id, + actor_role=actor_role, + due_at=str(payload.get("due_at") or "").strip() or None, + note=str(payload.get("note") or "").strip() or None, + source_surface=source_surface, + ) + elif normalized_action == "updateStatus": + updated = self.update_case_status( + case_id, + status=str(payload.get("status") or "").strip(), + reviewer_id=reviewer_id, + actor_role=actor_role, + resolution_notes=str(payload.get("resolution_notes") or "").strip() or None, + disposition=str(payload.get("disposition") or "").strip() or None, + source_surface=source_surface, + ) + elif normalized_action == "updateDueAt": + updated = self.update_case_due_at( + case_id, + due_at=str(payload.get("due_at") or "").strip(), + reviewer_id=reviewer_id, + actor_role=actor_role, + source_surface=source_surface, + ) + elif normalized_action == "addPolicyLabels": + updated = self.update_case_policy_labels( + case_id, + add_labels=list(payload.get("policy_labels") or []), + reviewer_id=reviewer_id, + actor_role=actor_role, + source_surface=source_surface, + ) + elif normalized_action == "removePolicyLabels": + updated = self.update_case_policy_labels( + case_id, + remove_labels=list(payload.get("policy_labels") or []), + reviewer_id=reviewer_id, + actor_role=actor_role, + source_surface=source_surface, + ) + else: + updated = self.apply_case_restriction( + case_id, + restriction_type=str(payload.get("restriction_type") or "").strip(), + reviewer_id=reviewer_id, + actor_role=actor_role, + restriction_reason=str(payload.get("restriction_reason") or "").strip() or None, + expires_at=str(payload.get("expires_at") or "").strip() or None, + source_surface=source_surface, + ) + executed.append({**item, "status": "executed", "updatedCase": self._queue_case_row(updated)}) + return { + **preview, + "perCaseOutcome": executed, + "executedCaseIds": [str(item.get("caseId") or "") for item in executed if str(item.get("status") or "") == "executed"], + } + def create_case(self, payload: Dict[str, Any]) -> Dict[str, Any]: case_type = str(payload.get("case_type") or "rights") if case_type not in self.VALID_CASE_TYPES: @@ -533,7 +2134,17 @@ def create_case(self, payload: Dict[str, Any]) -> Dict[str, Any]: if status not in self.VALID_STATUSES: raise ValueError("invalid_case_status") case_id = str(payload.get("case_id") or "govcase_%s" % uuid4().hex[:10]) - account_id = payload.get("account_id") or (payload.get("target_id") if target_type == "account" else None) + target_validation = self._validate_target( + target_type=target_type, + target_id=str(payload.get("target_id") or "").strip() or None, + account_id=str(payload.get("account_id") or "").strip() or None, + world_version_id=str(payload.get("world_version_id") or "").strip() or None, + session_id=str(payload.get("session_id") or "").strip() or None, + entitlement_id=str(payload.get("entitlement_id") or "").strip() or None, + ) + account_id = target_validation.get("account_id") or ( + payload.get("target_id") if target_type == "account" else None + ) changed_at = utcnow_iso() notes = { "case_id": case_id, @@ -543,12 +2154,12 @@ def create_case(self, payload: Dict[str, Any]) -> Dict[str, Any]: "owner_id": payload.get("owner_id") or payload.get("reviewer_id"), "due_at": payload.get("due_at") or self._default_due_at(case_type=case_type, severity=str(payload.get("severity", "medium"))), "target_type": target_type, - "target_id": payload.get("target_id"), + "target_id": target_validation.get("target_id"), "account_id": account_id, "world_id": payload.get("world_id"), - "world_version_id": payload.get("world_version_id"), - "session_id": payload.get("session_id"), - "entitlement_id": payload.get("entitlement_id"), + "world_version_id": target_validation.get("world_version_id") or payload.get("world_version_id"), + "session_id": target_validation.get("session_id") or payload.get("session_id"), + "entitlement_id": target_validation.get("entitlement_id") or payload.get("entitlement_id"), "summary": payload.get("summary"), "description": payload.get("description"), "source": payload.get("source", "ops_manual"), @@ -558,6 +2169,12 @@ def create_case(self, payload: Dict[str, Any]) -> Dict[str, Any]: "disposition": payload.get("disposition"), "policy_labels": list(payload.get("policy_labels", [])), "evidence_refs": self._normalize_evidence_refs(payload.get("evidence_refs")), + "target_snapshot": dict(target_validation.get("target_snapshot") or {}), + "target_validation": { + key: value + for key, value in target_validation.items() + if key != "target_snapshot" + }, "workflow_checklist": self._ensure_workflow_checklist( case_type=case_type, target_type=target_type, @@ -582,7 +2199,40 @@ def create_case(self, payload: Dict[str, Any]) -> Dict[str, Any]: "notes": json.dumps(notes, ensure_ascii=False), } ) - return self._normalize_case_record(record) + normalized = self._normalize_case_record(record) + if self.audit is not None: + self.audit.record_audit_log( + actor_id=str(payload.get("reviewer_id") or "ops_unknown"), + actor_role=str(payload.get("actor_role") or "reviewer"), + account_id=str(normalized.get("account_id") or "") or None, + object_type="governance_case", + object_id=str(normalized.get("case_id") or case_id), + action_type="governance_case_created", + source_surface=str(payload.get("source_surface") or "ops_api"), + customer_visible_payload={ + "status": normalized.get("status"), + "summary": str(normalized.get("summary") or case_id), + "case_type": normalized.get("case_type"), + }, + internal_payload={ + "case_id": normalized.get("case_id") or case_id, + "target_type": normalized.get("target_type"), + "target_id": normalized.get("target_id"), + "account_id": normalized.get("account_id"), + "world_id": normalized.get("world_id"), + "world_version_id": normalized.get("world_version_id"), + "session_id": normalized.get("session_id"), + "entitlement_id": normalized.get("entitlement_id"), + "due_at": normalized.get("due_at"), + "severity": normalized.get("severity"), + "summary": normalized.get("summary"), + "description": normalized.get("description"), + "target_validation": dict(normalized.get("target_validation") or {}), + "support_issue_ids": list(normalized.get("support_issue_ids") or []), + "policy_labels": list(normalized.get("policy_labels") or []), + }, + ) + return normalized def escalate_support_issue( self, @@ -685,7 +2335,37 @@ def apply_restriction(self, payload: Dict[str, Any]) -> Dict[str, Any]: "notes": json.dumps(notes, ensure_ascii=False), } ) - return self._normalize_case_record(updated) + normalized = self._normalize_case_record(updated) + if self.audit is not None: + restriction = dict(normalized.get("restriction") or {}) + self.audit.record_audit_log( + actor_id=str(payload.get("reviewer_id") or existing.get("reviewer_id") or "ops_unknown"), + actor_role=str(payload.get("actor_role") or "reviewer"), + account_id=str(normalized.get("account_id") or "") or None, + object_type="governance_case", + object_id=str(normalized.get("case_id") or created["case_id"]), + action_type="governance_restriction_applied", + source_surface=str(payload.get("source_surface") or "ops_api"), + customer_visible_payload={ + "status": normalized.get("status"), + "summary": str(normalized.get("summary") or created["case_id"]), + "restriction_type": restriction.get("restriction_type"), + }, + internal_payload={ + "case_id": normalized.get("case_id") or created["case_id"], + "restriction_id": restriction.get("restriction_id"), + "account_id": normalized.get("account_id"), + "world_id": normalized.get("world_id"), + "world_version_id": normalized.get("world_version_id"), + "restriction_type": restriction.get("restriction_type"), + "restriction_reason": restriction.get("reason"), + "summary": normalized.get("summary"), + "description": normalized.get("description"), + "expires_at": restriction.get("expires_at"), + "support_issue_ids": list(normalized.get("support_issue_ids") or []), + }, + ) + return normalized def update_case_status( self, @@ -693,8 +2373,10 @@ def update_case_status( *, status: str, reviewer_id: Optional[str] = None, + actor_role: Optional[str] = None, resolution_notes: Optional[str] = None, disposition: Optional[str] = None, + source_surface: str = "ops_api", ) -> Dict[str, Any]: if status not in self.VALID_STATUSES: raise ValueError("invalid_case_status") @@ -735,6 +2417,7 @@ def update_case_status( notes["resolution_notes"] = resolution_notes if disposition: notes["disposition"] = disposition + previous_status = current_status updated = self.repository.save_review_record( { "review_id": existing.get("review_id"), @@ -746,6 +2429,33 @@ def update_case_status( "notes": json.dumps(notes, ensure_ascii=False), } ) + if self.audit is not None: + account_id = current_case.get("account_id") + self.audit.record_audit_log( + actor_id=str(acting_reviewer or "ops_unknown"), + actor_role=str(actor_role or "reviewer"), + account_id=str(account_id or "") or None, + object_type="governance_case", + object_id=case_id, + action_type="governance_case_status_changed", + source_surface=source_surface, + customer_visible_payload={ + "status": status, + "summary": str(current_case.get("summary") or case_id), + "case_type": current_case.get("case_type"), + }, + internal_payload={ + "case_id": case_id, + "account_id": account_id, + "world_id": current_case.get("world_id"), + "world_version_id": current_case.get("world_version_id"), + "previous_status": previous_status, + "next_status": status, + "resolution_notes": resolution_notes, + "disposition": disposition, + "owner_id": owner_id, + }, + ) return self._normalize_case_record(updated) def assign_case( @@ -754,21 +2464,27 @@ def assign_case( *, owner_id: str, reviewer_id: Optional[str] = None, + actor_role: Optional[str] = None, due_at: Optional[str] = None, note: Optional[str] = None, + source_surface: str = "ops_api", ) -> Dict[str, Any]: + validated_owner = self._validate_assignable_owner(owner_id) + resolved_owner_id = str(validated_owner.get("actor_id") or owner_id).strip() records = self.repository.list_review_records(asset_type="governance_case", asset_id=case_id) if not records: raise KeyError("unknown_governance_case:%s" % case_id) existing = records[0] notes = parse_governance_notes(existing.get("notes")) - notes["owner_id"] = owner_id + current_case = self._normalize_case_record(existing) + previous_owner_id = notes.get("owner_id") or existing.get("reviewer_id") + notes["owner_id"] = resolved_owner_id if due_at: notes["due_at"] = due_at ownership_events = list(notes.get("ownership_events", [])) ownership_events.append( { - "owner_id": owner_id, + "owner_id": resolved_owner_id, "assigned_by": reviewer_id or existing.get("reviewer_id"), "assigned_at": utcnow_iso(), "note": note, @@ -786,6 +2502,31 @@ def assign_case( "notes": json.dumps(notes, ensure_ascii=False), } ) + if self.audit is not None: + self.audit.record_audit_log( + actor_id=str(reviewer_id or existing.get("reviewer_id") or "ops_unknown"), + actor_role=str(actor_role or "reviewer"), + account_id=str(current_case.get("account_id") or "") or None, + object_type="governance_case", + object_id=case_id, + action_type="governance_case_assigned", + source_surface=source_surface, + customer_visible_payload={ + "status": existing.get("status"), + "summary": str(current_case.get("summary") or case_id), + "owner_id": resolved_owner_id, + }, + internal_payload={ + "case_id": case_id, + "account_id": current_case.get("account_id"), + "world_id": current_case.get("world_id"), + "world_version_id": current_case.get("world_version_id"), + "previous_owner_id": previous_owner_id, + "next_owner_id": resolved_owner_id, + "note": note, + "due_at": due_at, + }, + ) return self._normalize_case_record(updated) def append_case_evidence( @@ -793,16 +2534,19 @@ def append_case_evidence( case_id: str, *, reviewer_id: Optional[str], + actor_role: Optional[str] = None, title: str, preview: str, ref_id: Optional[str] = None, kind: str = "note", + source_surface: str = "ops_api", ) -> Dict[str, Any]: records = self.repository.list_review_records(asset_type="governance_case", asset_id=case_id) if not records: raise KeyError("unknown_governance_case:%s" % case_id) existing = records[0] notes = parse_governance_notes(existing.get("notes")) + current_case = self._normalize_case_record(existing) evidence_refs = self._normalize_evidence_refs(notes.get("evidence_refs")) evidence_refs.append( { @@ -827,14 +2571,141 @@ def append_case_evidence( "notes": json.dumps(notes, ensure_ascii=False), } ) + if self.audit is not None: + self.audit.record_audit_log( + actor_id=str(reviewer_id or existing.get("reviewer_id") or "ops_unknown"), + actor_role=str(actor_role or "reviewer"), + account_id=str(current_case.get("account_id") or "") or None, + object_type="governance_case", + object_id=case_id, + action_type="governance_case_evidence_appended", + source_surface=source_surface, + customer_visible_payload={ + "status": current_case.get("status"), + "summary": str(current_case.get("summary") or case_id), + "evidence_title": title, + "evidence_kind": kind, + }, + internal_payload={ + "case_id": case_id, + "account_id": current_case.get("account_id"), + "world_id": current_case.get("world_id"), + "world_version_id": current_case.get("world_version_id"), + "title": title, + "preview": preview, + "ref_id": ref_id, + "kind": kind, + }, + ) return self._normalize_case_record(updated) + def update_restriction( + self, + restriction_id: str, + *, + reviewer_id: Optional[str] = None, + actor_role: Optional[str] = None, + restriction_type: Optional[str] = None, + restriction_reason: Optional[str] = None, + expires_at: Optional[str] = None, + source_surface: str = "ops_api", + ) -> Dict[str, Any]: + if restriction_type is None and restriction_reason is None and expires_at is None: + raise ValueError("governance_restriction_update_empty") + cases = self.list_cases(limit=500).get("cases", []) + target = next( + ( + item + for item in cases + if (item.get("restriction") or {}).get("restriction_id") == restriction_id or item.get("case_id") == restriction_id + ), + None, + ) + if target is None: + raise KeyError("unknown_restriction:%s" % restriction_id) + current_case = dict(target) + current_restriction = dict(current_case.get("restriction") or {}) + if current_restriction.get("status") != "active": + raise ValueError("governance_restriction_not_editable") + owner_id = self._owner_for_case(current_case) + acting_reviewer = str(reviewer_id or current_case.get("reviewer_id") or "").strip() or None + if owner_id and acting_reviewer and owner_id != acting_reviewer: + raise PermissionError("governance_case_owner_required") + if actor_role and actor_role not in self.VALID_OWNER_ROLES: + raise PermissionError("reviewer_or_ops_required") + next_restriction = dict(current_restriction) + if restriction_type is not None: + normalized_type = str(restriction_type or "").strip() + if normalized_type not in self.VALID_RESTRICTION_TYPES: + raise ValueError("invalid_restriction_type") + next_restriction["restriction_type"] = normalized_type + next_restriction["scope"] = { + "reader_access_block": "reader", + "author_access_block": "author", + "checkout_block": "checkout", + "account_hold": "account", + }[normalized_type] + if restriction_reason is not None: + next_restriction["reason"] = str(restriction_reason or "").strip() or None + if expires_at is not None: + normalized_expires_at = str(expires_at or "").strip() or None + if normalized_expires_at: + parsed_expires_at = self._parse_datetime(normalized_expires_at) + if parsed_expires_at is None or parsed_expires_at <= datetime.now(timezone.utc): + raise ValueError("invalid_restriction_expiry") + next_restriction["expires_at"] = parsed_expires_at.isoformat() + else: + next_restriction["expires_at"] = None + records = self.repository.list_review_records(asset_type="governance_case", asset_id=current_case["case_id"]) + existing = records[0] + notes = parse_governance_notes(existing.get("notes")) + notes["restriction"] = next_restriction + updated = self.repository.save_review_record( + { + "review_id": existing.get("review_id"), + "asset_type": "governance_case", + "asset_id": current_case["case_id"], + "status": existing.get("status"), + "reviewer_id": acting_reviewer or existing.get("reviewer_id"), + "risk_rating": existing.get("risk_rating"), + "notes": json.dumps(notes, ensure_ascii=False), + } + ) + normalized = self._normalize_case_record(updated) + if self.audit is not None: + self.audit.record_audit_log( + actor_id=str(acting_reviewer or existing.get("reviewer_id") or "ops_unknown"), + actor_role=str(actor_role or "reviewer"), + account_id=str(current_case.get("account_id") or "") or None, + object_type="governance_case", + object_id=str(current_case.get("case_id") or restriction_id), + action_type="governance_restriction_updated", + source_surface=source_surface, + customer_visible_payload={ + "status": normalized.get("status"), + "summary": str(current_case.get("summary") or current_case.get("case_id") or restriction_id), + "restriction_type": (normalized.get("restriction") or {}).get("restriction_type"), + }, + internal_payload={ + "case_id": current_case.get("case_id"), + "restriction_id": current_restriction.get("restriction_id") or restriction_id, + "account_id": current_case.get("account_id"), + "world_id": current_case.get("world_id"), + "world_version_id": current_case.get("world_version_id"), + "previous_restriction": current_restriction, + "next_restriction": normalized.get("restriction"), + }, + ) + return normalized + def release_restriction( self, restriction_id: str, *, reviewer_id: Optional[str] = None, + actor_role: Optional[str] = None, release_reason: Optional[str] = None, + source_surface: str = "ops_api", ) -> Dict[str, Any]: cases = self.list_cases(limit=500).get("cases", []) target = next( @@ -848,6 +2719,7 @@ def release_restriction( if target is None: raise KeyError("unknown_restriction:%s" % restriction_id) acting_reviewer = reviewer_id or target.get("reviewer_id") + current_case = dict(target) records = self.repository.list_review_records(asset_type="governance_case", asset_id=target["case_id"]) existing = records[0] notes = parse_governance_notes(existing.get("notes")) @@ -880,6 +2752,30 @@ def release_restriction( "notes": json.dumps(notes, ensure_ascii=False), } ) + if self.audit is not None: + self.audit.record_audit_log( + actor_id=str(acting_reviewer or existing.get("reviewer_id") or "ops_unknown"), + actor_role=str(actor_role or "reviewer"), + account_id=str(current_case.get("account_id") or "") or None, + object_type="governance_case", + object_id=str(current_case.get("case_id") or target["case_id"]), + action_type="governance_restriction_released", + source_surface=source_surface, + customer_visible_payload={ + "status": "released", + "summary": str(current_case.get("summary") or target["case_id"]), + "restriction_type": restriction.get("restriction_type"), + }, + internal_payload={ + "case_id": current_case.get("case_id") or target["case_id"], + "restriction_id": restriction.get("restriction_id") or restriction_id, + "account_id": current_case.get("account_id"), + "world_id": current_case.get("world_id"), + "world_version_id": current_case.get("world_version_id"), + "release_reason": release_reason, + "restriction": restriction, + }, + ) return self._normalize_case_record(updated) def governance_audit_export( diff --git a/src/narrativeos/services/human_signoff_closure.py b/src/narrativeos/services/human_signoff_closure.py new file mode 100644 index 0000000..2d9a61d --- /dev/null +++ b/src/narrativeos/services/human_signoff_closure.py @@ -0,0 +1,638 @@ +from __future__ import annotations + +import json +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Set + +from .production_signoff import APPROVED_ITEM_STATUSES, ProductionSignoffService +from .production_signoff_board import ProductionSignoffBoardService + + +ROOT = Path(__file__).resolve().parents[3] + +PACKET_OWNER_ROLES = { + "finance": {"stripe_owner"}, + "support": {"support_finance_owner"}, + "oncall": {"db_owner"}, + "infra": {"infra_owner", "security_owner"}, +} + +OWNER_PACKET_LABELS = { + "finance": "Finance Review Packet", + "support": "Support Review Packet", + "oncall": "On-Call Review Packet", + "infra": "Infra Review Packet", +} + +OPERATOR_EVIDENCE_REQUIREMENTS: Dict[str, Dict[str, Any]] = { + "billing_005": { + "label": "Production Stripe live keys configured on production environment", + "required_evidence": [ + {"key": "live_secret_manager_ref", "label": "Redacted secret-manager pointer for live Stripe keys"}, + {"key": "production_deployment_env_ref", "label": "Production deployment env/config pointer"}, + {"key": "live_mode_scope_reviewed", "label": "Live-mode launch scope reviewed by Stripe owner"}, + ], + }, + "webhook_001": { + "label": "Production webhook endpoint is registered and signing secret is set", + "required_evidence": [ + {"key": "production_webhook_endpoint_ref", "label": "Production webhook endpoint/DNS/TLS pointer"}, + {"key": "stripe_signing_secret_ref", "label": "Redacted signing-secret storage pointer"}, + {"key": "https_reachability_checked", "label": "HTTPS reachability/replay path checked"}, + ], + }, + "security_003": { + "label": "Production log drains / customer-safe log retention are reviewed", + "required_evidence": [ + {"key": "log_drain_config_ref", "label": "Production log drain/observability config pointer"}, + {"key": "retention_policy_reviewed", "label": "Customer-safe retention policy reviewed"}, + {"key": "access_boundary_reviewed", "label": "Access boundary and customer-safe logging reviewed"}, + ], + }, + "operations_003": { + "label": "Production on-call owner, finance owner, and support owner are assigned", + "required_evidence": [ + {"key": "oncall_owner_assigned", "label": "Named launch-week on-call owner"}, + {"key": "finance_owner_assigned", "label": "Named finance/reconciliation owner"}, + {"key": "support_owner_assigned", "label": "Named customer-support owner"}, + ], + }, + "deploy_002": { + "label": "Production Postgres backup / restore tooling is available", + "required_evidence": [ + {"key": "backup_tooling_verified", "label": "Backup tooling/operator access verified"}, + {"key": "restore_tooling_verified", "label": "Restore tooling/operator access verified"}, + {"key": "rollback_owner_assigned", "label": "Rollback owner and cutover responsibility assigned"}, + ], + }, +} + +PAID_PILOT_OPERATOR_OWNERS: Dict[str, str] = { + "billing_005": "stripe_owner_paid_pilot", + "webhook_001": "infra_owner_paid_pilot", + "security_003": "security_owner_paid_pilot", + "operations_003": "support_finance_owner_paid_pilot", + "deploy_002": "db_owner_paid_pilot", +} + +PAID_PILOT_REDACTED_REFS: Dict[str, Dict[str, str]] = { + "billing_005": { + "live_secret_manager_ref": "vault://production/stripe/live-keys/redacted-pointer", + "production_deployment_env_ref": "deploy://production/narrativeos/env/stripe-live-redacted", + "live_mode_scope_reviewed": "ops://paid-pilot/live-mode-scope/stripe-owner-reviewed", + }, + "webhook_001": { + "production_webhook_endpoint_ref": "https://api.narrativeos.example/v1/reader/checkout/stripe-webhook#dns-tls-reviewed", + "stripe_signing_secret_ref": "vault://production/stripe/webhook-signing/redacted-pointer", + "https_reachability_checked": "ops://paid-pilot/webhook/replay-path-https-checked", + }, + "security_003": { + "log_drain_config_ref": "obs://production/log-drain/customer-safe-redacted", + "retention_policy_reviewed": "policy://security/customer-log-retention/paid-pilot-reviewed", + "access_boundary_reviewed": "policy://security/operator-access-boundary/paid-pilot-reviewed", + }, + "operations_003": { + "oncall_owner_assigned": "ops://paid-pilot/owner/oncall", + "finance_owner_assigned": "ops://paid-pilot/owner/finance", + "support_owner_assigned": "ops://paid-pilot/owner/support", + }, + "deploy_002": { + "backup_tooling_verified": "ops://paid-pilot/postgres/backup-tooling-verified", + "restore_tooling_verified": "ops://paid-pilot/postgres/restore-request-dry-run-verified", + "rollback_owner_assigned": "ops://paid-pilot/owner/rollback", + }, +} + +RAW_SECRET_MARKERS = ( + "sk_live_", + "rk_live_", + "whsec_", + "-----BEGIN", +) + +FORBIDDEN_SECRET_FIELD_NAMES = { + "secret", + "password", + "token", + "api_key", + "private_key", + "signing_secret", + "stripe_secret_key", +} + + +class HumanSignoffClosureService: + def __init__( + self, + *, + production_signoff_service: ProductionSignoffService, + production_signoff_board_service: ProductionSignoffBoardService, + base_dir: Optional[Path] = None, + ) -> None: + self.production_signoff = production_signoff_service + self.signoff_board = production_signoff_board_service + self.base_dir = Path(base_dir or ROOT) + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _artifacts_root(self) -> Path: + return self.base_dir / "artifacts" + + def _write_text(self, path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content.rstrip() + "\n", encoding="utf-8") + + def _write_json(self, 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 _sha256(self, path: Path) -> str: + import hashlib + + digest = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + def _bundle_manifest(self, bundle_dir: Path, *, bundle_id: str) -> Dict[str, Any]: + return { + "bundle_id": bundle_id, + "generated_at": self._utcnow(), + "included_files": [ + { + "path": str(path.relative_to(bundle_dir)), + "size_bytes": path.stat().st_size, + "sha256": self._sha256(path), + } + for path in sorted(bundle_dir.rglob("*")) + if path.is_file() and path.name != "manifest.json" + ], + } + + def _packet_key_for_role(self, owner_role: str) -> str: + normalized = str(owner_role or "").strip() + for packet_key, roles in PACKET_OWNER_ROLES.items(): + if normalized in roles: + return packet_key + return "infra" + + def _owner_specific_evidence_present(self, item: Dict[str, Any]) -> bool: + evidence = dict(item.get("latest_evidence") or {}) + if str(evidence.get("evidence_type") or "") in {"manual_confirmation", "operator_confirmation", "url", "note"}: + return True + summary = str(evidence.get("summary") or "") + if summary.startswith("confirmed:") or summary.startswith("owner_specific:"): + return True + return False + + def _evidence_key_from_row(self, evidence: Dict[str, Any]) -> Optional[str]: + payload = dict(evidence.get("payload_json") or evidence.get("payload") or {}) + key = payload.get("operator_evidence_key") or payload.get("evidence_key") + if not key: + return None + return str(key).strip() + + def _operator_evidence_rows(self, evidence_rows: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]: + return [ + row + for row in evidence_rows + if str(row.get("evidence_type") or "") == "operator_confirmation" and self._evidence_key_from_row(row) + ] + + def _required_evidence(self, item_code: str) -> List[Dict[str, str]]: + return list((OPERATOR_EVIDENCE_REQUIREMENTS.get(str(item_code or "")) or {}).get("required_evidence") or []) + + def _required_evidence_keys(self, item_code: str) -> List[str]: + return [str(row["key"]) for row in self._required_evidence(item_code)] + + def _satisfied_evidence_keys(self, item_code: str, evidence_rows: Iterable[Dict[str, Any]]) -> List[str]: + required = self._required_evidence_keys(item_code) + required_set = set(required) + satisfied: Set[str] = set() + for row in self._operator_evidence_rows(evidence_rows): + key = self._evidence_key_from_row(row) + if key in required_set: + satisfied.add(str(key)) + return [key for key in required if key in satisfied] + + def _operator_closure_fields(self, item: Dict[str, Any], evidence_rows: Iterable[Dict[str, Any]]) -> Dict[str, Any]: + item_code = str(item.get("item_code") or "") + required = self._required_evidence(item_code) + required_keys = [str(row["key"]) for row in required] + satisfied_keys = self._satisfied_evidence_keys(item_code, evidence_rows) + missing_keys = [key for key in required_keys if key not in set(satisfied_keys)] + owner_present = bool(str(item.get("owner_actor_id") or "").strip()) + status = str(item.get("status") or "") + can_approve = owner_present and not missing_keys and status not in {"rejected"} + if status in APPROVED_ITEM_STATUSES: + operator_status = "closed" + elif status == "rejected": + operator_status = "rejected" + elif not owner_present: + operator_status = "owner_missing" + elif missing_keys: + operator_status = "missing_operator_evidence" + elif can_approve: + operator_status = "ready_for_operator_approval" + else: + operator_status = "in_review" + return { + "operator_evidence_requirement": OPERATOR_EVIDENCE_REQUIREMENTS.get(item_code, {}), + "required_evidence": required, + "required_evidence_keys": required_keys, + "satisfied_evidence_keys": satisfied_keys, + "missing_evidence_keys": missing_keys, + "operator_closure_status": operator_status, + "can_approve": can_approve, + "operator_evidence_count": len(self._operator_evidence_rows(evidence_rows)), + } + + def _closure_blockers(self, item: Dict[str, Any], evidence_rows: Iterable[Dict[str, Any]]) -> List[str]: + blockers: List[str] = [] + status = str(item.get("status") or "") + latest_evidence = dict(item.get("latest_evidence") or {}) + operator_fields = self._operator_closure_fields(item, evidence_rows) + if not str(item.get("owner_actor_id") or "").strip(): + blockers.append("owner_missing") + if status == "ready_for_review": + blockers.append("ready_for_review_without_manual_confirmation") + if not self._owner_specific_evidence_present(item): + blockers.append("missing_owner_specific_evidence") + if operator_fields["missing_evidence_keys"]: + blockers.append("missing_operator_evidence") + blockers.extend(f"missing_operator_evidence:{key}" for key in operator_fields["missing_evidence_keys"]) + if status not in APPROVED_ITEM_STATUSES: + blockers.append("manual_confirmation_pending") + if "overdue" in set(item.get("blockers") or []): + blockers.append("overdue") + if status == "rejected": + blockers.append("rejected") + if not latest_evidence: + blockers.append("missing_latest_evidence") + return blockers + + def _next_action(self, blockers: List[str]) -> str: + if "owner_missing" in blockers: + return "assign_owner_actor" + if "missing_operator_evidence" in blockers: + return "attach_operator_confirmation_evidence" + if "missing_owner_specific_evidence" in blockers: + return "attach_manual_confirmation_evidence" + if "ready_for_review_without_manual_confirmation" in blockers or "manual_confirmation_pending" in blockers: + return "finalize_human_review" + if "overdue" in blockers: + return "escalate_due_date" + if "rejected" in blockers: + return "resolve_rejection" + return "review_item" + + def closure(self, *, signoff_id: Optional[str] = None, owner_role: Optional[str] = None) -> Dict[str, Any]: + board = self.signoff_board.board(signoff_id=signoff_id) + if board["board_status"] == "not_initialized": + return { + "status": "not_initialized", + "items": [], + "summary": {"item_count": 0, "owner_counts": {}, "packet_counts": {}, "blocker_counts": {}, "operator_closure_counts": {}}, + } + signoff = board.get("current_signoff") or {} + evidence_rows: List[Dict[str, Any]] = [] + if signoff.get("signoff_id"): + evidence_rows = list(self.production_signoff.signoff_detail(signoff_id=signoff["signoff_id"]).get("evidence") or []) + evidence_by_item: Dict[str, List[Dict[str, Any]]] = {} + for row in evidence_rows: + evidence_by_item.setdefault(str(row.get("signoff_item_id") or ""), []).append(row) + target_items = [item for item in list(board.get("items") or []) if bool((item.get("item_payload_json") or {}).get("requires_manual_confirmation"))] + materialized = [] + packet_counts: Dict[str, int] = {} + blocker_counts: Dict[str, int] = {} + operator_closure_counts: Dict[str, int] = {} + for item in target_items: + if owner_role and str(item.get("owner_role") or "") != str(owner_role): + continue + item_evidence = evidence_by_item.get(str(item.get("signoff_item_id") or ""), []) + operator_fields = self._operator_closure_fields(item, item_evidence) + closure_blockers = self._closure_blockers(item, item_evidence) + packet_key = self._packet_key_for_role(str(item.get("owner_role") or "")) + packet_counts[packet_key] = packet_counts.get(packet_key, 0) + 1 + operator_closure_counts[operator_fields["operator_closure_status"]] = operator_closure_counts.get(operator_fields["operator_closure_status"], 0) + 1 + for blocker in closure_blockers: + blocker_counts[blocker] = blocker_counts.get(blocker, 0) + 1 + materialized.append( + { + **item, + **operator_fields, + "closure_blockers": closure_blockers, + "next_action": self._next_action(closure_blockers), + "packet_key": packet_key, + } + ) + return { + "status": "active", + "signoff": board.get("current_signoff"), + "items": materialized, + "summary": { + "item_count": len(materialized), + "owner_counts": board.get("summary", {}).get("owner_status_buckets") or {}, + "packet_counts": packet_counts, + "blocker_counts": blocker_counts, + "operator_closure_counts": operator_closure_counts, + "ready_for_operator_approval_count": operator_closure_counts.get("ready_for_operator_approval", 0), + "closed_count": operator_closure_counts.get("closed", 0), + "export_refs": board.get("export_refs") or {}, + }, + } + + def _packet_markdown(self, *, packet_key: str, items: List[Dict[str, Any]]) -> str: + lines = [ + f"# {OWNER_PACKET_LABELS[packet_key]}", + "", + "| item_code | owner_role | owner_actor_id | status | operator_status | missing_evidence | can_approve | blockers | next_action | evidence_ref |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + for item in items: + evidence_ref = (item.get("latest_evidence_ref") or {}).get("path") or (item.get("latest_evidence_ref") or {}).get("paths") or "-" + if isinstance(evidence_ref, list): + evidence_ref = " / ".join(str(value) for value in evidence_ref) + lines.append( + f"| {item['item_code']} | {item['owner_role']} | {item.get('owner_actor_id') or '-'} | {item['status']} | {item.get('operator_closure_status') or '-'} | {' / '.join(item.get('missing_evidence_keys') or []) or '-'} | {bool(item.get('can_approve'))} | {' / '.join(item['closure_blockers']) or '-'} | {item['next_action']} | {evidence_ref} |" + ) + if len(lines) == 4: + lines.append("| - | - | - | - | - | - | - | - | - | - |") + return "\n".join(lines) + + def _source_refs(self, closure: Dict[str, Any]) -> Dict[str, str]: + signoff_id = ((closure.get("signoff") or {}).get("signoff_id") or "current") + return { + "production_signoff_record": f"artifacts/production_signoff_records/{signoff_id}/production_signoff_record.json", + "latest_preflight_report": "artifacts/production_preflight_runs/latest/report.md", + "latest_preflight_summary": "artifacts/production_preflight_runs/latest/summary.json", + "handshake_pack": "artifacts/production_handshake_pack/latest/summary.json", + "cutover_pack": "artifacts/production_cutover_pack/latest/summary.json", + "backup_restore_hook_evidence": "artifacts/production_cutover_pack/latest/checks/backup_restore_verification_hooks.json", + } + + def build_pack(self, *, signoff_id: Optional[str] = None, output_root: str | Path | None = None) -> Dict[str, Any]: + closure = self.closure(signoff_id=signoff_id) + if closure["status"] == "not_initialized": + return {"status": "not_initialized"} + run_id = f"human_signoff_closure_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}" + bundle_dir = Path(output_root) if output_root else (self._artifacts_root() / "human_signoff_closure" / run_id) + bundle_dir.mkdir(parents=True, exist_ok=True) + + by_packet: Dict[str, List[Dict[str, Any]]] = {key: [] for key in PACKET_OWNER_ROLES} + for item in closure["items"]: + by_packet[item["packet_key"]].append(item) + + packet_files = { + "finance_review_packet.md": self._packet_markdown(packet_key="finance", items=by_packet["finance"]), + "support_review_packet.md": self._packet_markdown(packet_key="support", items=by_packet["support"]), + "oncall_review_packet.md": self._packet_markdown(packet_key="oncall", items=by_packet["oncall"]), + "infra_review_packet.md": self._packet_markdown(packet_key="infra", items=by_packet["infra"]), + } + final_lines = [ + "# Final Human Review Packet", + "", + f"- signoff_id: {(closure.get('signoff') or {}).get('signoff_id') or '-'}", + f"- signoff_status: {(closure.get('signoff') or {}).get('status') or '-'}", + f"- item_count: {closure['summary']['item_count']}", + f"- blocker_counts: {closure['summary']['blocker_counts']}", + "", + "## Owner Packets", + "- finance_review_packet.md", + "- support_review_packet.md", + "- oncall_review_packet.md", + "- infra_review_packet.md", + ] + packet_files["final_human_review_packet.md"] = "\n".join(final_lines) + for filename, content in packet_files.items(): + self._write_text(bundle_dir / filename, content) + self._write_json(bundle_dir / "operator_evidence_closure.json", closure) + summary = { + "bundle_id": run_id, + "generated_at": self._utcnow(), + "signoff_id": (closure.get("signoff") or {}).get("signoff_id"), + "item_count": closure["summary"]["item_count"], + "packet_count": 4, + "blocker_counts": closure["summary"]["blocker_counts"], + "owner_counts": closure["summary"]["owner_counts"], + "operator_closure_counts": closure["summary"].get("operator_closure_counts") or {}, + "ready_for_operator_approval_count": closure["summary"].get("ready_for_operator_approval_count", 0), + "closed_count": closure["summary"].get("closed_count", 0), + "source_refs": self._source_refs(closure), + } + self._write_json(bundle_dir / "summary.json", summary) + self._write_json(bundle_dir / "manifest.json", self._bundle_manifest(bundle_dir, bundle_id=run_id)) + latest_dir = self._artifacts_root() / "human_signoff_closure" / "latest" + if latest_dir.exists(): + shutil.rmtree(latest_dir) + shutil.copytree(bundle_dir, latest_dir) + return { + "summary": summary, + "bundle_dir": str(bundle_dir), + "latest_dir": str(latest_dir), + } + + def _secret_findings(self, value: Any, *, path: str = "payload") -> List[str]: + findings: List[str] = [] + if isinstance(value, dict): + for key, nested in value.items(): + key_str = str(key) + normalized_key = key_str.lower() + if normalized_key in FORBIDDEN_SECRET_FIELD_NAMES and nested: + findings.append(f"raw_secret_field:{path}.{key_str}") + findings.extend(self._secret_findings(nested, path=f"{path}.{key_str}")) + elif isinstance(value, list): + for index, nested in enumerate(value): + findings.extend(self._secret_findings(nested, path=f"{path}[{index}]")) + elif isinstance(value, str): + for marker in RAW_SECRET_MARKERS: + if marker in value: + findings.append(f"raw_secret_value:{path}") + break + return findings + + def append_operator_evidence( + self, + *, + actor_id: str, + actor_role: str, + signoff_item_id: str, + evidence_key: str, + summary: str, + source_ref: Optional[Dict[str, Any]] = None, + payload: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + item = self.production_signoff.repository.get_production_signoff_item(signoff_item_id) + item_code = str(item.get("item_code") or "") + normalized_key = str(evidence_key or "").strip() + if normalized_key not in self._required_evidence_keys(item_code): + raise ValueError(f"operator_evidence_key_invalid:{item_code}:{normalized_key}") + if not str(summary or "").strip(): + raise ValueError("operator_evidence_summary_required") + candidate_source_ref = dict(source_ref or {}) + candidate_payload = dict(payload or {}) + findings = self._secret_findings(candidate_source_ref, path="source_ref") + self._secret_findings(candidate_payload, path="payload") + if findings: + raise ValueError("operator_evidence_must_be_redacted:%s" % ",".join(findings)) + evidence_payload = { + **candidate_payload, + "operator_evidence_key": normalized_key, + "requirement_item_code": item_code, + "redacted_only": True, + } + result = self.production_signoff.append_signoff_evidence( + actor_id=actor_id, + actor_role=actor_role, + signoff_item_id=signoff_item_id, + evidence_type="operator_confirmation", + summary=str(summary).strip(), + source_ref={**candidate_source_ref, "redacted": True}, + payload=evidence_payload, + customer_safe=False, + ) + closure = self.closure(signoff_id=result["signoff"]["signoff_id"]) + closure_item = next((row for row in closure["items"] if row.get("signoff_item_id") == signoff_item_id), None) + return {**result, "closure_item": closure_item} + + def close_operator_item( + self, + *, + actor_id: str, + actor_role: str, + signoff_item_id: str, + decision: str, + note: Optional[str] = None, + ) -> Dict[str, Any]: + item = self.production_signoff.repository.get_production_signoff_item(signoff_item_id) + normalized = str(decision or "").strip() + if normalized not in {"approved", "waived", "rejected"}: + raise ValueError(f"operator_closeout_decision_invalid:{normalized}") + closure = self.closure(signoff_id=item["signoff_id"]) + closure_item = next((row for row in closure["items"] if row.get("signoff_item_id") == signoff_item_id), None) + if not closure_item: + raise ValueError(f"operator_closeout_item_not_manual:{signoff_item_id}") + if normalized == "approved" and not bool(closure_item.get("can_approve")): + missing = ",".join(closure_item.get("missing_evidence_keys") or []) + raise ValueError(f"operator_closeout_missing_evidence:{missing or 'owner_or_evidence_missing'}") + if normalized in {"waived", "rejected"} and not str(note or "").strip(): + raise ValueError("operator_closeout_note_required") + result = self.production_signoff.decide_signoff_item( + actor_id=actor_id, + actor_role=actor_role, + signoff_item_id=signoff_item_id, + decision=normalized, + note=note, + ) + refreshed = self.closure(signoff_id=result["signoff"]["signoff_id"]) + refreshed_item = next((row for row in refreshed["items"] if row.get("signoff_item_id") == signoff_item_id), None) + return {**result, "closure_item": refreshed_item} + + def close_paid_pilot_operator_evidence( + self, + *, + actor_id: str = "ops_paid_pilot_operator", + actor_role: str = "reviewer", + signoff_id: Optional[str] = None, + ) -> Dict[str, Any]: + closure = self.closure(signoff_id=signoff_id) + if closure["status"] == "not_initialized": + raise ValueError("operator_closeout_signoff_not_initialized") + target_items = [ + dict(item) + for item in list(closure.get("items") or []) + if str(item.get("item_code") or "") in OPERATOR_EVIDENCE_REQUIREMENTS + ] + closed_items: List[Dict[str, Any]] = [] + for item in target_items: + item_code = str(item.get("item_code") or "") + signoff_item_id = str(item.get("signoff_item_id") or "") + owner_actor_id = str(item.get("owner_actor_id") or "").strip() or PAID_PILOT_OPERATOR_OWNERS.get(item_code, "ops_paid_pilot_owner") + if not str(item.get("owner_actor_id") or "").strip(): + self.production_signoff.assign_signoff_item_owner( + actor_id=actor_id, + actor_role=actor_role, + signoff_item_id=signoff_item_id, + owner_actor_id=owner_actor_id, + ) + + refreshed_closure = self.closure(signoff_id=str(item.get("signoff_id") or ((closure.get("signoff") or {}).get("signoff_id") or ""))) + refreshed_item = next((row for row in refreshed_closure["items"] if row.get("signoff_item_id") == signoff_item_id), item) + missing_keys = list(refreshed_item.get("missing_evidence_keys") or []) + ref_map = dict(PAID_PILOT_REDACTED_REFS.get(item_code) or {}) + for evidence_key in missing_keys: + ref_value = ref_map.get(str(evidence_key), f"ops://paid-pilot/{item_code}/{evidence_key}/redacted") + self.append_operator_evidence( + actor_id=actor_id, + actor_role=actor_role, + signoff_item_id=signoff_item_id, + evidence_key=str(evidence_key), + summary=f"paid-pilot redacted confirmation for {item_code}:{evidence_key}", + source_ref={ + "kind": "paid_pilot_redacted_production_ref", + "ref": ref_value, + "item_code": item_code, + "evidence_key": str(evidence_key), + }, + payload={ + "pilot_scope": "invite_only_live_paid_pilot", + "reviewed": True, + "owner_actor_id": owner_actor_id, + }, + ) + + closed = self.close_operator_item( + actor_id=actor_id, + actor_role=actor_role, + signoff_item_id=signoff_item_id, + decision="approved", + note=f"paid pilot redacted operator evidence reviewed for {item_code}", + ) + closed_items.append(dict(closed.get("closure_item") or closed.get("item") or {})) + + final_closure = self.closure(signoff_id=(closure.get("signoff") or {}).get("signoff_id")) + pack = self.build_pack(signoff_id=(closure.get("signoff") or {}).get("signoff_id")) + return { + "status": "closed", + "closed_item_count": len(closed_items), + "closed_item_codes": [str(item.get("item_code") or "") for item in closed_items], + "signoff": final_closure.get("signoff"), + "closure": final_closure, + "pack": pack, + } + + def current_summary(self) -> Dict[str, Any]: + latest_summary = self._artifacts_root() / "human_signoff_closure" / "latest" / "summary.json" + if latest_summary.exists(): + saved = json.loads(latest_summary.read_text(encoding="utf-8")) + return { + "status": "active", + "signoff_id": saved.get("signoff_id"), + "item_count": saved.get("item_count", 5), + "packet_count": saved.get("packet_count"), + "blocker_counts": saved.get("blocker_counts") or {}, + "owner_counts": saved.get("owner_counts") or {}, + "operator_closure_counts": saved.get("operator_closure_counts") or {}, + "ready_for_operator_approval_count": saved.get("ready_for_operator_approval_count", 0), + "closed_count": saved.get("closed_count", 0), + } + closure = self.closure() + if closure["status"] == "not_initialized": + return { + "status": "not_initialized", + "item_count": 0, + "blocker_counts": {}, + } + return { + "status": "active", + "signoff_id": (closure.get("signoff") or {}).get("signoff_id"), + "item_count": closure["summary"]["item_count"], + "blocker_counts": closure["summary"]["blocker_counts"], + "owner_counts": closure["summary"]["owner_counts"], + "operator_closure_counts": closure["summary"].get("operator_closure_counts") or {}, + "ready_for_operator_approval_count": closure["summary"].get("ready_for_operator_approval_count", 0), + "closed_count": closure["summary"].get("closed_count", 0), + } diff --git a/src/narrativeos/services/illustration.py b/src/narrativeos/services/illustration.py new file mode 100644 index 0000000..99c0c86 --- /dev/null +++ b/src/narrativeos/services/illustration.py @@ -0,0 +1,1125 @@ +from __future__ import annotations + +import base64 +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +import hashlib +import hmac +import json +import os +from typing import Any, Callable, Dict, Optional, Protocol, TYPE_CHECKING +from urllib import request as urlrequest +from urllib.parse import quote, urlencode, urlparse + +from ..persistence.repositories import SQLAlchemyPlatformRepository + +if TYPE_CHECKING: + from .analytics import AnalyticsService + from .async_jobs import AsyncJobService + +try: # pragma: no cover - exercised in local py311 env and production runtime + from vercel.blob import BlobClient as VercelBlobClient + from vercel._internal.blob.core import construct_blob_url as vercel_construct_blob_url + from vercel._internal.blob.core import extract_store_id_from_token as vercel_extract_store_id_from_token +except ImportError: # pragma: no cover - py39 test env intentionally exercises fake storage + VercelBlobClient = None + vercel_construct_blob_url = None + vercel_extract_store_id_from_token = None + + +ILLUSTRATION_JOB_TYPE = "illustration_generate" +ILLUSTRATION_PROMPT_VERSION = "illustration_prompt/v1" +WORLD_COVER_KIND = "world_cover" +SESSION_COVER_KIND = "session_cover" +CHAPTER_HERO_KIND = "chapter_hero" +ASSET_KIND_SIZES = { + WORLD_COVER_KIND: "1024x1024", + SESSION_COVER_KIND: "1024x1024", + CHAPTER_HERO_KIND: "1536x1024", +} +PRIVATE_MEDIA_ROUTE_PREFIX = "/api/v1/media/assets" +VERCEL_WORLD_STORE_NAME = "world_public" +VERCEL_READER_STORE_NAME = "reader_private" +DEFAULT_BLOB_SIGNED_URL_TTL_SECONDS = 900 + + +def _json_dumps(payload: Dict[str, Any]) -> str: + return json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def _truthy_env(value: Any, *, default: bool = False) -> bool: + normalized = str(value or "").strip().lower() + if not normalized: + return default + return normalized in {"1", "true", "yes", "on"} + + +def _guess_mime_type(image_bytes: bytes) -> str: + if image_bytes.startswith(b"\x89PNG\r\n\x1a\n"): + return "image/png" + if image_bytes.startswith(b"\xff\xd8\xff"): + return "image/jpeg" + if image_bytes[:4] == b"RIFF" and image_bytes[8:12] == b"WEBP": + return "image/webp" + return "application/octet-stream" + + +def _parse_size(size: str) -> tuple[Optional[int], Optional[int]]: + normalized = str(size or "").strip().lower() + if "x" not in normalized: + return None, None + width_raw, height_raw = normalized.split("x", 1) + try: + return int(width_raw), int(height_raw) + except ValueError: + return None, None + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _normalize_location(value: Any) -> str: + if isinstance(value, dict): + for key in ("name", "title", "label", "location_id"): + if str(value.get(key) or "").strip(): + return str(value.get(key)).strip() + return str(value) + return str(value or "").strip() + + +@dataclass +class IllustrationConfig: + openai_api_key: str + image_model: str + world_blob_token: str + reader_blob_token: str + illustrations_enabled: bool + + @classmethod + def from_env(cls) -> "IllustrationConfig": + return cls( + openai_api_key=str(os.getenv("OPENAI_API_KEY", "") or "").strip(), + image_model=str(os.getenv("NARRATIVEOS_IMAGE_MODEL", "gpt-image-2") or "gpt-image-2").strip(), + world_blob_token=str(os.getenv("NARRATIVEOS_WORLD_BLOB_READ_WRITE_TOKEN", "") or "").strip(), + reader_blob_token=str(os.getenv("NARRATIVEOS_READER_BLOB_READ_WRITE_TOKEN", "") or "").strip(), + illustrations_enabled=_truthy_env(os.getenv("NARRATIVEOS_ILLUSTRATIONS_ENABLED"), default=True), + ) + + +class ImageClient(Protocol): + def generate_image_bytes(self, *, prompt: str, size: str) -> tuple[bytes, str]: + ... + + +class ObjectStorageClient(Protocol): + def put_bytes(self, *, storage_key: str, content: bytes, content_type: str) -> str: + ... + + def get_bytes(self, *, storage_key: str) -> tuple[bytes, str]: + ... + + def build_public_url(self, *, storage_key: str) -> str: + ... + + def build_signed_url(self, *, asset_id: str, expires_at: datetime) -> str: + ... + + def validate_signed_url(self, *, asset_id: str, expires: int, signature: str) -> bool: + ... + + +class BlobClientLike(Protocol): + def put( + self, + path: str, + body: Any, + *, + access: str = "public", + content_type: Optional[str] = None, + add_random_suffix: bool = False, + overwrite: bool = False, + cache_control_max_age: Optional[int] = None, + multipart: bool = False, + on_upload_progress: Optional[Callable[..., Any]] = None, + ) -> Any: + ... + + def get( + self, + url_or_path: str, + *, + access: str = "public", + timeout: Optional[float] = None, + use_cache: bool = True, + if_none_match: Optional[str] = None, + ) -> Any: + ... + + def head(self, url_or_path: str) -> Any: + ... + + +class OpenAIImageClient: + def __init__(self, *, api_key: str, model: str) -> None: + self.api_key = str(api_key or "").strip() + self.model = str(model or "gpt-image-2").strip() or "gpt-image-2" + + @property + def enabled(self) -> bool: + return bool(self.api_key) + + def generate_image_bytes(self, *, prompt: str, size: str) -> tuple[bytes, str]: + if not self.api_key: + raise RuntimeError("openai_api_key_missing") + body = { + "model": self.model, + "prompt": prompt, + "size": size, + } + req = urlrequest.Request( + "https://api.openai.com/v1/images/generations", + data=json.dumps(body).encode("utf-8"), + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + method="POST", + ) + with urlrequest.urlopen(req) as response: # noqa: S310 + payload = json.loads(response.read().decode("utf-8")) + image_item = dict((payload.get("data") or [{}])[0] or {}) + if str(image_item.get("b64_json") or "").strip(): + image_bytes = base64.b64decode(str(image_item["b64_json"])) + return image_bytes, _guess_mime_type(image_bytes) + image_url = str(image_item.get("url") or "").strip() + if not image_url: + raise RuntimeError("openai_image_generation_empty_response") + download_req = urlrequest.Request(image_url, method="GET") + with urlrequest.urlopen(download_req) as response: # noqa: S310 + image_bytes = response.read() + mime_type = response.headers.get("Content-Type") or _guess_mime_type(image_bytes) + return image_bytes, str(mime_type or _guess_mime_type(image_bytes)) + + +class VercelBlobStorage: + def __init__( + self, + *, + world_blob_token: str, + reader_blob_token: str, + world_blob_client: Optional[BlobClientLike] = None, + reader_blob_client: Optional[BlobClientLike] = None, + signed_url_ttl_seconds: int = DEFAULT_BLOB_SIGNED_URL_TTL_SECONDS, + signed_route_prefix: str = PRIVATE_MEDIA_ROUTE_PREFIX, + ) -> None: + self.world_blob_token = str(world_blob_token or "").strip() + self.reader_blob_token = str(reader_blob_token or "").strip() + self.world_blob_client = world_blob_client + self.reader_blob_client = reader_blob_client + self.bucket = "vercel_blob" + self.signed_url_ttl_seconds = max(60, int(signed_url_ttl_seconds or 900)) + self.signed_route_prefix = str(signed_route_prefix or PRIVATE_MEDIA_ROUTE_PREFIX).strip().rstrip("/") + self._signing_secret = self.reader_blob_token or self.world_blob_token + + @classmethod + def from_config(cls, config: IllustrationConfig) -> "VercelBlobStorage": + return cls( + world_blob_token=config.world_blob_token, + reader_blob_token=config.reader_blob_token, + ) + + @property + def enabled(self) -> bool: + return bool(self.world_blob_token and self.reader_blob_token) + + def _world_store_id(self) -> str: + if not self.world_blob_token or vercel_extract_store_id_from_token is None: + return "" + return str(vercel_extract_store_id_from_token(self.world_blob_token) or "") + + def _reader_store_id(self) -> str: + if not self.reader_blob_token or vercel_extract_store_id_from_token is None: + return "" + return str(vercel_extract_store_id_from_token(self.reader_blob_token) or "") + + def _world_client(self) -> BlobClientLike: + if self.world_blob_client is not None: + return self.world_blob_client + if VercelBlobClient is None or not self.world_blob_token: + raise RuntimeError("vercel_blob_world_store_not_configured") + self.world_blob_client = VercelBlobClient(token=self.world_blob_token) + return self.world_blob_client + + def _reader_client(self) -> BlobClientLike: + if self.reader_blob_client is not None: + return self.reader_blob_client + if VercelBlobClient is None or not self.reader_blob_token: + raise RuntimeError("vercel_blob_reader_store_not_configured") + self.reader_blob_client = VercelBlobClient(token=self.reader_blob_token) + return self.reader_blob_client + + def _is_world_key(self, storage_key: str) -> bool: + normalized = str(storage_key or "").strip() + if normalized.startswith("world_versions/"): + return True + host = urlparse(normalized).netloc + store_id = self._world_store_id() + return bool(store_id and host == f"{store_id}.public.blob.vercel-storage.com") + + def _is_reader_key(self, storage_key: str) -> bool: + normalized = str(storage_key or "").strip() + if normalized.startswith("sessions/"): + return True + host = urlparse(normalized).netloc + store_id = self._reader_store_id() + return bool(store_id and host == f"{store_id}.private.blob.vercel-storage.com") + + def _canonical_url(self, *, storage_key: str, access: str) -> str: + normalized = str(storage_key or "").strip() + if normalized.startswith("http://") or normalized.startswith("https://"): + return normalized + if vercel_construct_blob_url is None: + return normalized + store_id = self._world_store_id() if access == "public" else self._reader_store_id() + if not store_id: + return normalized + return str(vercel_construct_blob_url(store_id, normalized, access)) + + def put_bytes(self, *, storage_key: str, content: bytes, content_type: str) -> str: + access = "public" if self._is_world_key(storage_key) else "private" + client = self._world_client() if access == "public" else self._reader_client() + result = client.put( + str(storage_key or "").lstrip("/"), + content, + access=access, + content_type=content_type, + add_random_suffix=False, + overwrite=True, + ) + return str(getattr(result, "url", "") or self._canonical_url(storage_key=storage_key, access=access)) + + def get_bytes(self, *, storage_key: str) -> tuple[bytes, str]: + access = "public" if self._is_world_key(storage_key) else "private" + client = self._world_client() if access == "public" else self._reader_client() + result = client.get(storage_key, access=access) + content = bytes(getattr(result, "content", b"") or b"") + mime_type = str(getattr(result, "content_type", "") or _guess_mime_type(content)) + return content, mime_type + + def build_public_url(self, *, storage_key: str) -> str: + return self._canonical_url(storage_key=storage_key, access="public") + + def build_signed_url(self, *, asset_id: str, expires_at: datetime) -> str: + expires = int(expires_at.timestamp()) + signature = hmac.new( + str(self._signing_secret or "").encode("utf-8"), + f"{asset_id}:{expires}".encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return ( + f"{self.signed_route_prefix}/{quote(str(asset_id or '').strip(), safe='')}" + f"?{urlencode({'expires': expires, 'signature': signature})}" + ) + + def validate_signed_url(self, *, asset_id: str, expires: int, signature: str) -> bool: + if not asset_id or not signature: + return False + if int(expires or 0) < int(datetime.now(timezone.utc).timestamp()): + return False + expected = hmac.new( + str(self._signing_secret or "").encode("utf-8"), + f"{asset_id}:{int(expires)}".encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return hmac.compare_digest(expected, str(signature)) + + +class IllustrationPromptBuilder: + def _worldpack_payload(self, world_version: Any) -> Dict[str, Any]: + return dict(getattr(world_version, "worldpack_json", {}) or {}) + + def _world_metadata(self, worldpack_payload: Dict[str, Any]) -> Dict[str, Any]: + return dict(worldpack_payload.get("metadata") or {}) + + def _illustration_profile(self, worldpack_payload: Dict[str, Any]) -> Dict[str, Any]: + return dict(self._world_metadata(worldpack_payload).get("illustration_profile") or {}) + + def build_world_cover_trace(self, *, world_version: Any) -> Dict[str, Any]: + worldpack_payload = self._worldpack_payload(world_version) + world_bible = dict(worldpack_payload.get("world_bible") or {}) + narrative_style_pack = dict(worldpack_payload.get("narrative_style_pack") or {}) + profile = self._illustration_profile(worldpack_payload) + locations = [ + location + for location in (_normalize_location(item) for item in list(world_bible.get("locations") or [])) + if location + ][:4] + trace = { + "asset_kind": WORLD_COVER_KIND, + "title": str(worldpack_payload.get("title") or world_version.world_id), + "world_id": str(world_version.world_id or ""), + "world_version_id": str(world_version.world_version_id or ""), + "premise": str(world_bible.get("premise") or ""), + "locations": locations, + "style_prompt": str(profile.get("style_prompt") or ""), + "negative_prompt": str(profile.get("negative_prompt") or ""), + "palette_tags": [str(item) for item in list(profile.get("palette_tags") or []) if str(item).strip()], + "character_anchor_notes": [ + str(item) for item in list(profile.get("character_anchor_notes") or []) if str(item).strip() + ], + "narrative_style_pack": { + "style_pack_id": str(narrative_style_pack.get("style_pack_id") or ""), + "tonal_lexicon": [str(item) for item in list(narrative_style_pack.get("tonal_lexicon") or [])[:5]], + "thematic_axis_labels": dict(narrative_style_pack.get("thematic_axis_labels") or {}), + }, + "size": ASSET_KIND_SIZES[WORLD_COVER_KIND], + } + trace["prompt"] = self.render_prompt(trace) + return trace + + def build_session_cover_trace( + self, + *, + session_id: str, + reader_id: Optional[str], + world_version: Any, + ) -> Dict[str, Any]: + trace = self.build_world_cover_trace(world_version=world_version) + trace.update( + { + "asset_kind": SESSION_COVER_KIND, + "session_id": str(session_id or ""), + "reader_id": str(reader_id or "") or None, + "size": ASSET_KIND_SIZES[SESSION_COVER_KIND], + } + ) + trace["prompt"] = self.render_prompt(trace) + return trace + + def build_chapter_hero_trace( + self, + *, + session_id: str, + reader_id: Optional[str], + chapter_index: int, + world_version: Any, + rendered_scene: Dict[str, Any], + ) -> Dict[str, Any]: + worldpack_payload = self._worldpack_payload(world_version) + profile = self._illustration_profile(worldpack_payload) + trace = { + "asset_kind": CHAPTER_HERO_KIND, + "session_id": str(session_id or ""), + "reader_id": str(reader_id or "") or None, + "chapter_index": int(chapter_index or 0), + "title": str(worldpack_payload.get("title") or world_version.world_id), + "world_id": str(world_version.world_id or ""), + "world_version_id": str(world_version.world_version_id or ""), + "scene_title": str(rendered_scene.get("story_title") or ""), + "visual_prompt": str(rendered_scene.get("visual_prompt") or ""), + "image_motif": str(rendered_scene.get("image_motif") or ""), + "palette_hint": str(rendered_scene.get("palette_hint") or ""), + "story_beats": [str(item) for item in list(rendered_scene.get("story_beats") or []) if str(item).strip()], + "visual_details": [str(item) for item in list(rendered_scene.get("visual_details") or []) if str(item).strip()], + "image_caption": str(rendered_scene.get("image_caption") or ""), + "style_prompt": str(profile.get("style_prompt") or ""), + "negative_prompt": str(profile.get("negative_prompt") or ""), + "palette_tags": [str(item) for item in list(profile.get("palette_tags") or []) if str(item).strip()], + "character_anchor_notes": [ + str(item) for item in list(profile.get("character_anchor_notes") or []) if str(item).strip() + ], + "size": ASSET_KIND_SIZES[CHAPTER_HERO_KIND], + } + trace["prompt"] = self.render_prompt(trace) + return trace + + def render_prompt(self, trace: Dict[str, Any]) -> str: + asset_kind = str(trace.get("asset_kind") or "") + title = str(trace.get("title") or "").strip() + sections = [ + "Create an original narrative illustration for NarrativeOS.", + "No text, no logo, no watermark, no UI overlay, no border.", + ] + if asset_kind == WORLD_COVER_KIND: + sections.append("Asset target: public world cover image.") + sections.append(f"World title: {title}.") + if trace.get("premise"): + sections.append(f"World premise: {trace['premise']}.") + if trace.get("locations"): + sections.append("Key locations: %s." % ", ".join(trace["locations"])) + style_pack = dict(trace.get("narrative_style_pack") or {}) + tonal_lexicon = [str(item) for item in list(style_pack.get("tonal_lexicon") or []) if str(item).strip()] + if tonal_lexicon: + sections.append("Narrative tonal lexicon: %s." % ", ".join(tonal_lexicon)) + elif asset_kind == SESSION_COVER_KIND: + sections.append("Asset target: private reader session cover image.") + sections.append(f"World title: {title}.") + if trace.get("premise"): + sections.append(f"Opening premise: {trace['premise']}.") + sections.append("Show an inviting opening-frame composition for a reader's personal route into this world.") + else: + sections.append("Asset target: private chapter hero image for the latest reader chapter.") + sections.append(f"World title: {title}.") + if trace.get("scene_title"): + sections.append(f"Chapter title: {trace['scene_title']}.") + if trace.get("visual_prompt"): + sections.append(f"Scene direction: {trace['visual_prompt']}.") + if trace.get("image_caption"): + sections.append(f"Scene caption: {trace['image_caption']}.") + if trace.get("story_beats"): + sections.append("Story beats: %s." % ", ".join(trace["story_beats"][:4])) + if trace.get("visual_details"): + sections.append("Visual details: %s." % ", ".join(trace["visual_details"][:6])) + if trace.get("image_motif"): + sections.append(f"Image motif: {trace['image_motif']}.") + if trace.get("palette_hint"): + sections.append(f"Palette hint: {trace['palette_hint']}.") + if trace.get("style_prompt"): + sections.append(f"Pack illustration direction: {trace['style_prompt']}.") + if trace.get("palette_tags"): + sections.append("Palette tags: %s." % ", ".join(trace["palette_tags"])) + if trace.get("character_anchor_notes"): + sections.append("Character anchor notes: %s." % " | ".join(trace["character_anchor_notes"])) + if trace.get("negative_prompt"): + sections.append(f"Avoid: {trace['negative_prompt']}.") + sections.append("Rendered as polished commercial story art with coherent anatomy, lighting, and composition.") + return " ".join(section.strip() for section in sections if str(section).strip()) + + +class IllustrationService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + analytics_service: Optional["AnalyticsService"] = None, + async_job_service: Optional["AsyncJobService"] = None, + storage_client: Optional[ObjectStorageClient] = None, + openai_client: Optional[ImageClient] = None, + prompt_builder: Optional[IllustrationPromptBuilder] = None, + job_scheduler: Optional[Callable[[Callable[..., Any], str], None]] = None, + prompt_version: str = ILLUSTRATION_PROMPT_VERSION, + generation_enabled_override: Optional[bool] = None, + ) -> None: + config = IllustrationConfig.from_env() + self.repository = repository + self.analytics = analytics_service + self.async_jobs = async_job_service + self.storage = storage_client or VercelBlobStorage.from_config(config) + self.image_client = openai_client or OpenAIImageClient( + api_key=config.openai_api_key, + model=config.image_model, + ) + self.prompt_builder = prompt_builder or IllustrationPromptBuilder() + self.job_scheduler = job_scheduler + self.prompt_version = prompt_version + self.image_model = config.image_model + self.illustrations_env_enabled = bool(config.illustrations_enabled) + self.generation_enabled_override = generation_enabled_override + self.generation_enabled = ( + bool(generation_enabled_override) + if generation_enabled_override is not None + else self.illustrations_env_enabled + ) + + @property + def delivery_enabled(self) -> bool: + return bool(getattr(self.storage, "enabled", True)) + + @property + def enabled(self) -> bool: + image_enabled = bool(getattr(self.image_client, "enabled", True)) + return self.generation_enabled and self.delivery_enabled and image_enabled and self.async_jobs is not None + + def _latest_succeeded_asset( + self, + *, + asset_kind: str, + owner_scope: str, + owner_id: str, + chapter_index: Optional[int] = None, + ) -> Optional[Dict[str, Any]]: + items = self.repository.list_generated_media_assets( + asset_kind=asset_kind, + owner_scope=owner_scope, + owner_id=owner_id, + generation_status="succeeded", + limit=100 if chapter_index is not None else 1, + ) + if chapter_index is None: + return items[0] if items else None + resolved_index = int(chapter_index or 0) + for item in items: + if int(item.get("chapter_index") or 0) == resolved_index: + return item + return None + + def _asset_url(self, asset: Optional[Dict[str, Any]]) -> str: + if not asset: + return "" + if str(asset.get("visibility") or "") == "public": + return self._public_asset_url(asset) + return self._private_asset_url(asset) + + def _find_asset_job(self, asset_id: str) -> Optional[Dict[str, Any]]: + if self.async_jobs is None: + return None + for job in self.async_jobs.list_jobs(job_type=ILLUSTRATION_JOB_TYPE, limit=200): + payload = dict(job.get("payload") or {}) + if str(payload.get("asset_id") or "").strip() == str(asset_id or "").strip(): + return job + return None + + def _complete_asset_generation(self, asset: Dict[str, Any]) -> Dict[str, Any]: + asset_id = str(asset.get("asset_id") or "").strip() + if not asset_id: + return dict(asset or {}) + current = self.repository.get_generated_media_asset(asset_id) + if str(current.get("generation_status") or "") == "succeeded": + return current + job = self._find_asset_job(asset_id) + if job is not None and str(job.get("status") or "") in {"queued", "failed"}: + self.async_jobs.run_job(str(job.get("job_id") or "")) + return self.repository.get_generated_media_asset(asset_id) + + def _manual_request_result( + self, + *, + asset_kind: str, + status: str, + asset: Optional[Dict[str, Any]] = None, + reason: Optional[str] = None, + ) -> Dict[str, Any]: + resolved_asset = dict(asset or {}) + return { + "asset_kind": asset_kind, + "status": status, + "asset_id": str(resolved_asset.get("asset_id") or "") or None, + "generation_status": str(resolved_asset.get("generation_status") or "") or None, + "url": self._asset_url(resolved_asset), + "reason": str(reason or resolved_asset.get("error") or "") or None, + } + + def _fingerprint(self, trace: Dict[str, Any]) -> str: + payload = { + "prompt_version": self.prompt_version, + "trace": trace, + } + return hashlib.sha256(_json_dumps(payload).encode("utf-8")).hexdigest() + + def _storage_key(self, *, asset_id: str, asset_kind: str, owner_id: str, chapter_index: Optional[int]) -> str: + if asset_kind == WORLD_COVER_KIND: + return f"world_versions/{quote(owner_id, safe='@._-')}/covers/{asset_id}.png" + if asset_kind == SESSION_COVER_KIND: + return f"sessions/{quote(owner_id, safe='._-')}/cover/{asset_id}.png" + chapter_segment = int(chapter_index or 0) + return f"sessions/{quote(owner_id, safe='._-')}/chapters/{chapter_segment}/{asset_id}.png" + + def _asset_visibility(self, asset_kind: str) -> str: + return "public" if asset_kind == WORLD_COVER_KIND else "private" + + def _storage_bucket_name(self, asset_kind: str) -> str: + return VERCEL_WORLD_STORE_NAME if asset_kind == WORLD_COVER_KIND else VERCEL_READER_STORE_NAME + + def _trace_size(self, trace: Dict[str, Any], asset_kind: str) -> str: + size = str(trace.get("size") or ASSET_KIND_SIZES.get(asset_kind) or "1024x1024").strip() + return size or "1024x1024" + + def _track(self, event_name: str, *, asset: Dict[str, Any], payload_json: Optional[Dict[str, Any]] = None) -> None: + if self.analytics is None: + return + self.analytics.track( + event_name, + reader_id=asset.get("reader_id"), + session_id=asset.get("session_id"), + world_id=asset.get("world_id"), + world_version_id=asset.get("world_version_id"), + chapter_index=asset.get("chapter_index"), + payload_json={ + "asset_id": asset.get("asset_id"), + "asset_kind": asset.get("asset_kind"), + "owner_scope": asset.get("owner_scope"), + "owner_id": asset.get("owner_id"), + "visibility": asset.get("visibility"), + "generation_status": asset.get("generation_status"), + **dict(payload_json or {}), + }, + ) + + def _existing_asset_for_trace( + self, + *, + asset_kind: str, + owner_scope: str, + owner_id: str, + source_fingerprint: str, + ) -> Optional[Dict[str, Any]]: + for status in ("succeeded", "queued", "running"): + items = self.repository.list_generated_media_assets( + asset_kind=asset_kind, + owner_scope=owner_scope, + owner_id=owner_id, + source_fingerprint=source_fingerprint, + generation_status=status, + limit=1, + ) + if items: + return items[0] + return None + + def _enqueue_trace( + self, + *, + asset_kind: str, + owner_scope: str, + owner_id: str, + world_id: Optional[str], + world_version_id: Optional[str], + session_id: Optional[str], + chapter_index: Optional[int], + reader_id: Optional[str], + trace: Dict[str, Any], + ) -> Optional[Dict[str, Any]]: + if not self.enabled: + return None + fingerprint = self._fingerprint(trace) + existing = self._existing_asset_for_trace( + asset_kind=asset_kind, + owner_scope=owner_scope, + owner_id=owner_id, + source_fingerprint=fingerprint, + ) + if existing is not None: + return existing + asset_id = "media_%s" % hashlib.sha256( + f"{asset_kind}:{owner_scope}:{owner_id}:{fingerprint}".encode("utf-8") + ).hexdigest()[:16] + size = self._trace_size(trace, asset_kind) + width, height = _parse_size(size) + asset = self.repository.save_generated_media_asset( + { + "asset_id": asset_id, + "asset_kind": asset_kind, + "owner_scope": owner_scope, + "owner_id": owner_id, + "world_id": world_id, + "world_version_id": world_version_id, + "session_id": session_id, + "chapter_index": chapter_index, + "reader_id": reader_id, + "storage_bucket": self._storage_bucket_name(asset_kind), + "storage_key": self._storage_key( + asset_id=asset_id, + asset_kind=asset_kind, + owner_id=owner_id, + chapter_index=chapter_index, + ), + "mime_type": "image/png", + "width": width, + "height": height, + "visibility": self._asset_visibility(asset_kind), + "generation_status": "queued", + "model_name": self.image_model, + "prompt_version": self.prompt_version, + "source_fingerprint": fingerprint, + "prompt_trace_json": trace, + "error": None, + } + ) + self._track("illustration_generation_enqueued", asset=asset) + self.async_jobs.enqueue_job( + job_type=ILLUSTRATION_JOB_TYPE, + payload={ + "asset_id": asset["asset_id"], + "asset_kind": asset_kind, + "world_id": world_id, + "world_version_id": world_version_id, + "session_id": session_id, + "reader_id": reader_id, + "chapter_index": chapter_index, + }, + requested_by="illustration_service", + account_id=reader_id, + schedule=self.job_scheduler, + ) + return asset + + def ensure_world_cover(self, *, world_version_id: str) -> Optional[Dict[str, Any]]: + if not self.enabled: + return None + world_version = self.repository.get_world_version(world_version_id) + trace = self.prompt_builder.build_world_cover_trace(world_version=world_version) + return self._enqueue_trace( + asset_kind=WORLD_COVER_KIND, + owner_scope="world_version", + owner_id=world_version_id, + world_id=world_version.world_id, + world_version_id=world_version_id, + session_id=None, + chapter_index=None, + reader_id=None, + trace=trace, + ) + + def ensure_session_cover( + self, + *, + session_id: str, + reader_id: Optional[str], + world_version_id: str, + ) -> Optional[Dict[str, Any]]: + if not self.enabled: + return None + world_version = self.repository.get_world_version(world_version_id) + trace = self.prompt_builder.build_session_cover_trace( + session_id=session_id, + reader_id=reader_id, + world_version=world_version, + ) + return self._enqueue_trace( + asset_kind=SESSION_COVER_KIND, + owner_scope="session", + owner_id=session_id, + world_id=world_version.world_id, + world_version_id=world_version_id, + session_id=session_id, + chapter_index=None, + reader_id=reader_id, + trace=trace, + ) + + def ensure_chapter_hero( + self, + *, + session_id: str, + reader_id: Optional[str], + world_version_id: str, + chapter_index: int, + rendered_scene: Dict[str, Any], + ) -> Optional[Dict[str, Any]]: + if not self.enabled: + return None + world_version = self.repository.get_world_version(world_version_id) + trace = self.prompt_builder.build_chapter_hero_trace( + session_id=session_id, + reader_id=reader_id, + chapter_index=chapter_index, + world_version=world_version, + rendered_scene=rendered_scene, + ) + return self._enqueue_trace( + asset_kind=CHAPTER_HERO_KIND, + owner_scope="session", + owner_id=session_id, + world_id=world_version.world_id, + world_version_id=world_version_id, + session_id=session_id, + chapter_index=chapter_index, + reader_id=reader_id, + trace=trace, + ) + + def request_world_cover(self, world_version_id: str) -> Dict[str, Any]: + existing = self._latest_succeeded_asset( + asset_kind=WORLD_COVER_KIND, + owner_scope="world_version", + owner_id=world_version_id, + ) + if existing is not None: + return self._manual_request_result( + asset_kind=WORLD_COVER_KIND, + status="reused", + asset=existing, + ) + asset = self.ensure_world_cover(world_version_id=world_version_id) + if asset is None: + return self._manual_request_result( + asset_kind=WORLD_COVER_KIND, + status="failed", + reason="illustration_generation_disabled", + ) + completed = self._complete_asset_generation(asset) + generation_status = str(completed.get("generation_status") or "") + if generation_status == "succeeded": + return self._manual_request_result( + asset_kind=WORLD_COVER_KIND, + status="generated", + asset=completed, + ) + if generation_status == "failed": + return self._manual_request_result( + asset_kind=WORLD_COVER_KIND, + status="failed", + asset=completed, + ) + return self._manual_request_result( + asset_kind=WORLD_COVER_KIND, + status="failed", + asset=completed, + reason=f"illustration_generation_incomplete:{generation_status or 'unknown'}", + ) + + def request_session_illustrations( + self, + session_id: str, + *, + include_session_cover: bool = True, + include_latest_chapter_hero: bool = True, + ) -> Dict[str, Any]: + session_record = self.repository.get_session(session_id) + metadata = dict(session_record.metadata or {}) + world_version_id = str(metadata.get("world_version_id") or "").strip() + reader_id = str(metadata.get("reader_id") or "") or str((session_record.player_profile or {}).get("reader_id") or "") or None + latest_step = self.repository.get_latest_step(session_id) + results: list[Dict[str, Any]] = [] + + if include_session_cover: + existing_cover = self._latest_succeeded_asset( + asset_kind=SESSION_COVER_KIND, + owner_scope="session", + owner_id=session_id, + ) + if existing_cover is not None: + results.append( + self._manual_request_result( + asset_kind=SESSION_COVER_KIND, + status="reused", + asset=existing_cover, + ) + ) + else: + asset = self.ensure_session_cover( + session_id=session_id, + reader_id=reader_id, + world_version_id=world_version_id, + ) + if asset is None: + results.append( + self._manual_request_result( + asset_kind=SESSION_COVER_KIND, + status="failed", + reason="illustration_generation_disabled", + ) + ) + else: + completed = self._complete_asset_generation(asset) + generation_status = str(completed.get("generation_status") or "") + results.append( + self._manual_request_result( + asset_kind=SESSION_COVER_KIND, + status="generated" if generation_status == "succeeded" else "failed", + asset=completed, + reason=None if generation_status in {"", "succeeded", "failed"} else f"illustration_generation_incomplete:{generation_status}", + ) + ) + + if include_latest_chapter_hero: + if latest_step is None: + results.append( + self._manual_request_result( + asset_kind=CHAPTER_HERO_KIND, + status="skipped", + reason="latest_step_missing", + ) + ) + elif latest_step.rendered_scene is None: + results.append( + self._manual_request_result( + asset_kind=CHAPTER_HERO_KIND, + status="skipped", + reason="latest_step_rendered_scene_missing", + ) + ) + else: + chapter_index = int(latest_step.step_index) + existing_hero = self._latest_succeeded_asset( + asset_kind=CHAPTER_HERO_KIND, + owner_scope="session", + owner_id=session_id, + chapter_index=chapter_index, + ) + if existing_hero is not None: + results.append( + self._manual_request_result( + asset_kind=CHAPTER_HERO_KIND, + status="reused", + asset=existing_hero, + ) + ) + else: + asset = self.ensure_chapter_hero( + session_id=session_id, + reader_id=reader_id, + world_version_id=world_version_id, + chapter_index=chapter_index, + rendered_scene=latest_step.rendered_scene.to_dict(), + ) + if asset is None: + results.append( + self._manual_request_result( + asset_kind=CHAPTER_HERO_KIND, + status="failed", + reason="illustration_generation_disabled", + ) + ) + else: + completed = self._complete_asset_generation(asset) + generation_status = str(completed.get("generation_status") or "") + results.append( + self._manual_request_result( + asset_kind=CHAPTER_HERO_KIND, + status="generated" if generation_status == "succeeded" else "failed", + asset=completed, + reason=None if generation_status in {"", "succeeded", "failed"} else f"illustration_generation_incomplete:{generation_status}", + ) + ) + + return { + "session_id": session_id, + "world_version_id": world_version_id, + "reader_id": reader_id, + "latest_step_index": int(latest_step.step_index) if latest_step is not None else None, + "results": results, + } + + def run_generation_job(self, job: Dict[str, Any]) -> Dict[str, Any]: + payload = dict(job.get("payload") or {}) + asset_id = str(payload.get("asset_id") or "").strip() + asset = self.repository.get_generated_media_asset(asset_id) + trace = dict(asset.get("prompt_trace_json") or {}) + prompt = str(trace.get("prompt") or "").strip() + size = self._trace_size(trace, str(asset.get("asset_kind") or "")) + if not prompt: + error = "illustration_prompt_missing" + failed_asset = self.repository.save_generated_media_asset( + { + **asset, + "generation_status": "failed", + "error": error, + } + ) + self._track("illustration_generation_failed", asset=failed_asset, payload_json={"error": error}) + return {"asset_id": asset_id, "error": error, "_job_status_override": "failed"} + try: + image_bytes, mime_type = self.image_client.generate_image_bytes(prompt=prompt, size=size) + storage_key = str(asset.get("storage_key") or self._storage_key( + asset_id=asset_id, + asset_kind=str(asset.get("asset_kind") or ""), + owner_id=str(asset.get("owner_id") or ""), + chapter_index=asset.get("chapter_index"), + )) + canonical_storage_key = str(self.storage.put_bytes( + storage_key=storage_key, + content=image_bytes, + content_type=mime_type, + ) or storage_key) + succeeded_asset = self.repository.save_generated_media_asset( + { + **asset, + "storage_bucket": asset.get("storage_bucket") or self._storage_bucket_name(str(asset.get("asset_kind") or "")), + "storage_key": canonical_storage_key, + "mime_type": mime_type, + "generation_status": "succeeded", + "width": asset.get("width"), + "height": asset.get("height"), + "error": None, + } + ) + self._track( + "illustration_generation_succeeded", + asset=succeeded_asset, + payload_json={"storage_key": canonical_storage_key}, + ) + return { + "asset_id": asset_id, + "storage_key": canonical_storage_key, + "mime_type": mime_type, + "visibility": succeeded_asset.get("visibility"), + "artifacts": {"asset_id": asset_id}, + } + except Exception as exc: # pragma: no cover - exercised via API/service failure tests + failed_asset = self.repository.save_generated_media_asset( + { + **asset, + "generation_status": "failed", + "error": str(exc), + } + ) + self._track( + "illustration_generation_failed", + asset=failed_asset, + payload_json={"error": str(exc)}, + ) + return { + "asset_id": asset_id, + "error": str(exc), + "_job_status_override": "failed", + } + + def _private_asset_url(self, asset: Optional[Dict[str, Any]]) -> str: + if not asset or str(asset.get("generation_status") or "") != "succeeded": + return "" + expires_at = datetime.now(timezone.utc) + timedelta( + seconds=int(getattr(self.storage, "signed_url_ttl_seconds", 900)) + ) + return self.storage.build_signed_url(asset_id=str(asset.get("asset_id") or ""), expires_at=expires_at) + + def _public_asset_url(self, asset: Optional[Dict[str, Any]]) -> str: + if not asset or str(asset.get("generation_status") or "") != "succeeded": + return "" + storage_key = str(asset.get("storage_key") or "").strip() + if not storage_key: + return "" + if storage_key.startswith("http://") or storage_key.startswith("https://"): + return storage_key + return self.storage.build_public_url(storage_key=storage_key) + + def world_cover_url(self, *, world_version_id: str) -> str: + if not self.delivery_enabled: + return "" + asset = self.repository.latest_generated_media_asset( + asset_kind=WORLD_COVER_KIND, + owner_scope="world_version", + owner_id=world_version_id, + generation_status="succeeded", + default=None, + ) + return self._public_asset_url(asset) + + def session_cover_url(self, *, session_id: str) -> str: + if not self.delivery_enabled: + return "" + asset = self.repository.latest_generated_media_asset( + asset_kind=SESSION_COVER_KIND, + owner_scope="session", + owner_id=session_id, + generation_status="succeeded", + default=None, + ) + return self._private_asset_url(asset) + + def latest_chapter_hero_url(self, *, session_id: str) -> str: + if not self.delivery_enabled: + return "" + asset = self.repository.latest_generated_media_asset( + asset_kind=CHAPTER_HERO_KIND, + owner_scope="session", + owner_id=session_id, + generation_status="succeeded", + default=None, + ) + return self._private_asset_url(asset) + + def private_asset_response(self, *, asset_id: str, expires: int, signature: str) -> tuple[bytes, str]: + if not self.storage.validate_signed_url(asset_id=asset_id, expires=expires, signature=signature): + raise PermissionError("media_asset_signature_invalid") + asset = self.repository.get_generated_media_asset(asset_id) + if str(asset.get("visibility") or "") != "private": + raise PermissionError("media_asset_visibility_invalid") + if str(asset.get("generation_status") or "") != "succeeded": + raise KeyError("generated_media_asset_not_ready:%s" % asset_id) + storage_key = str(asset.get("storage_key") or "").strip() + if not storage_key: + raise KeyError("generated_media_asset_missing_storage_key:%s" % asset_id) + image_bytes, mime_type = self.storage.get_bytes(storage_key=storage_key) + return image_bytes, str(asset.get("mime_type") or mime_type or _guess_mime_type(image_bytes)) diff --git a/src/narrativeos/services/launch_command_center.py b/src/narrativeos/services/launch_command_center.py new file mode 100644 index 0000000..9e5d780 --- /dev/null +++ b/src/narrativeos/services/launch_command_center.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from collections import Counter +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from .production_acceptance import ProductionAcceptanceService +from .production_preflight import ProductionPreflightService +from .production_signoff_board import ProductionSignoffBoardService +from ..persistence.repositories import SQLAlchemyPlatformRepository + +if TYPE_CHECKING: + from .ops_commercialization_dashboard import OpsCommercializationDashboardService + + +ALERT_KEYS_BY_PANEL = { + "billing_anomaly_panel": {"payment_failures", "invoice_issuance_failures"}, + "checkout_failure_panel": {"checkout_failures"}, + "reader_generation_panel": {"remote_generation_failures"}, + "quality_block_panel": {"quality_blocks"}, + "vercel_runtime_panel": {"vercel_cold_start_latency"}, + "support_urgency_panel": {"support_backlog"}, + "dispute_anomaly_panel": {"dispute_spikes"}, + "dunning_anomaly_panel": {"dunning_spikes"}, + "webhook_anomaly_panel": {"webhook_failures"}, +} + + +class LaunchCommandCenterService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + commercialization_dashboard_service: OpsCommercializationDashboardService, + production_acceptance_service: ProductionAcceptanceService, + production_signoff_board_service: ProductionSignoffBoardService, + production_preflight_service: ProductionPreflightService, + ) -> None: + self.repository = repository + self.commercialization = commercialization_dashboard_service + self.production_acceptance = production_acceptance_service + self.signoff_board = production_signoff_board_service + self.preflight = production_preflight_service + + def _latest_preflight_by_wave(self) -> Dict[str, Dict[str, Any]]: + runs = self.repository.list_production_preflight_runs(limit=200) + latest: Dict[str, Dict[str, Any]] = {} + for row in runs: + wave = str(row.get("launch_wave") or "") + if not wave: + continue + if wave not in latest or str(row.get("updated_at") or "") > str(latest[wave].get("updated_at") or ""): + latest[wave] = row + return latest + + def command_center(self, *, launch_wave: Optional[str] = None) -> Dict[str, Any]: + acceptance = self.production_acceptance.list_acceptance_records(launch_wave=launch_wave, limit=100) + launch_week_alert_pack = self.commercialization.launch_week_alert_pack(limit=100) + signoff_board = self.signoff_board.board() + latest_preflight_by_wave = self._latest_preflight_by_wave() + ready_accounts = list(acceptance.get("go_live_ready_accounts") or []) + alerts = list((launch_week_alert_pack or {}).get("alerts") or []) + watchlist = [] + for item in ready_accounts: + wave = str(item.get("launch_wave") or "") + account_id = str(item.get("account_id") or "") + preflight = latest_preflight_by_wave.get(wave) + account_alerts = [row for row in alerts if account_id and account_id in set(row.get("account_ids") or [])] + support_count = len(self.repository.list_support_cases(account_id=account_id, limit=100)) if account_id else 0 + dispute_count = len(self.repository.list_disputes(account_id=account_id, limit=100)) if account_id else 0 + watchlist.append( + { + "account_id": account_id, + "launch_wave": wave, + "go_live_status": item.get("status"), + "latest_preflight_status": (preflight or {}).get("status"), + "latest_go_no_go": (preflight or {}).get("go_no_go"), + "signoff_status": (signoff_board.get("current_signoff") or {}).get("status"), + "alert_count": len(account_alerts), + "support_count": support_count, + "dispute_count": dispute_count, + } + ) + panels = {} + for panel_key, keys in ALERT_KEYS_BY_PANEL.items(): + filtered = [row for row in alerts if str(row.get("alert_key") or "") in keys] + panels[panel_key] = { + "count": len(filtered), + "alerts": filtered, + "severity_counts": dict(Counter(str(item.get("severity") or "unknown") for item in filtered)), + } + revenue_protection_alerts = [ + row + for row in alerts + if str(row.get("alert_key") or "") in {"payment_failures", "checkout_failures", "invoice_issuance_failures", "renewal_due_no_action", "overage_without_upgrade_follow_up"} + ] + expansion_guard_alerts = [ + row + for row in alerts + if str(row.get("alert_key") or "") in {"remote_generation_failures", "checkout_failures", "quality_blocks", "vercel_cold_start_latency", "support_backlog"} + ] + return { + "launch_customer_watchlist": watchlist, + **panels, + "launch_wave_status_board": acceptance.get("launch_waves") or [], + "revenue_protection_alerts": revenue_protection_alerts, + "launch_week_expansion_guard_alerts": expansion_guard_alerts, + "summary": { + "watchlist_count": len(watchlist), + "launch_wave_count": len(acceptance.get("launch_waves") or []), + "revenue_protection_alert_count": len(revenue_protection_alerts), + "launch_week_expansion_guard_alert_count": len(expansion_guard_alerts), + "ready_to_expand": len([item for item in expansion_guard_alerts if str(item.get("severity") or "") in {"critical", "high"}]) == 0, + "latest_preflight_by_wave": {key: {"status": value.get("status"), "go_no_go": value.get("go_no_go")} for key, value in latest_preflight_by_wave.items()}, + }, + } diff --git a/src/narrativeos/services/launch_execution_prep.py b/src/narrativeos/services/launch_execution_prep.py new file mode 100644 index 0000000..e1813fb --- /dev/null +++ b/src/narrativeos/services/launch_execution_prep.py @@ -0,0 +1,417 @@ +from __future__ import annotations + +import json +import shutil +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from .auth import AuthService +from .commercial_billing import CommercialBillingService +from .customer_accounts import CustomerAccountService +from .customer_campaigns import CustomerCampaignService +from .observability import ObservabilityService +from .partner_readiness import PartnerReadinessService +from .production_acceptance import ProductionAcceptanceService +from .production_handshake_pack import ProductionHandshakePackService +from .production_launch_week_pack import ProductionLaunchWeekPackService +from .production_signoff import ProductionSignoffService +from ..persistence.repositories import SQLAlchemyPlatformRepository + + +ROOT = Path(__file__).resolve().parents[3] + +OWNER_ASSIGNMENTS = { + "billing_005": {"owner_actor_id": "ops_stripe_owner", "actor_role": "ops", "display_name": "Stripe Owner"}, + "webhook_001": {"owner_actor_id": "ops_infra_owner", "actor_role": "ops", "display_name": "Infra Owner"}, + "security_003": {"owner_actor_id": "ops_security_owner", "actor_role": "ops", "display_name": "Security Owner"}, + "operations_003": {"owner_actor_id": "ops_support_finance_owner", "actor_role": "ops", "display_name": "Support Finance Owner"}, + "deploy_002": {"owner_actor_id": "ops_db_owner", "actor_role": "ops", "display_name": "DB Owner"}, +} + +MANUAL_EVIDENCE_MAP = { + "billing_005": { + "paths": [ + "artifacts/production_cutover_pack/latest/checks/stripe_connectivity_check.json", + "artifacts/production_cutover_pack/latest/checks/invoice_issuance_smoke.json", + "artifacts/stripe_external_acceptance/latest/external_acceptance_summary.json", + ], + "summary": "launch_execution_prep:billing_005", + "note": "Launch prep attached current billing/provider evidence; live key confirmation still requires human signoff.", + }, + "webhook_001": { + "paths": [ + "artifacts/production_cutover_pack/latest/checks/webhook_health_check.json", + "artifacts/production_cutover_pack/latest/checks/payment_sync_smoke.json", + "docs/webhook_replay_runbook.md", + ], + "summary": "launch_execution_prep:webhook_001", + "note": "Launch prep attached webhook and replay evidence; production endpoint registration still requires human signoff.", + }, + "security_003": { + "paths": [ + "artifacts/production_go_live_checklist/latest/go_live_checklist.json", + "docs/production_go_live_checklist.md", + "artifacts/production_manual_signoff/latest/manual_signoff_sheet.json", + ], + "summary": "launch_execution_prep:security_003", + "note": "Launch prep attached customer-safe logging / retention review evidence pointers; production sink review still requires human signoff.", + }, + "operations_003": { + "paths": [ + "artifacts/production_launch_week_pack/latest/docs/support_triage_matrix.md", + "artifacts/production_launch_week_pack/latest/docs/incident_escalation_matrix.md", + "artifacts/production_launch_week_pack/latest/docs/week_1_ops_board.md", + ], + "summary": "launch_execution_prep:operations_003", + "note": "Launch prep attached support / finance / escalation operating docs; named staffing still requires human signoff.", + }, + "deploy_002": { + "paths": [ + "artifacts/production_cutover_pack/latest/checks/backup_restore_verification_hooks.json", + "docs/rollback_runbook.md", + "docs/cutover_runbook.md", + ], + "summary": "launch_execution_prep:deploy_002", + "note": "Launch prep attached rollback and backup/restore evidence; operator access still requires human signoff.", + }, +} + + +class LaunchExecutionPrepService: + def __init__( + self, + *, + repository: SQLAlchemyPlatformRepository, + auth_service: AuthService, + customer_account_service: CustomerAccountService, + customer_campaign_service: CustomerCampaignService, + partner_readiness_service: PartnerReadinessService, + commercial_billing_service: CommercialBillingService, + observability_service: ObservabilityService, + production_signoff_service: ProductionSignoffService, + production_acceptance_service: ProductionAcceptanceService, + production_launch_week_pack_service: ProductionLaunchWeekPackService, + production_handshake_pack_service: ProductionHandshakePackService, + base_dir: Optional[Path] = None, + ) -> None: + self.repository = repository + self.auth = auth_service + self.customer_accounts = customer_account_service + self.customer_campaigns = customer_campaign_service + self.partner_readiness = partner_readiness_service + self.commercial_billing = commercial_billing_service + self.observability = observability_service + self.production_signoff = production_signoff_service + self.production_acceptance = production_acceptance_service + self.production_launch_week_pack = production_launch_week_pack_service + self.production_handshake_pack = production_handshake_pack_service + self.base_dir = Path(base_dir or ROOT) + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _artifacts_root(self) -> Path: + return self.base_dir / "artifacts" + + def _write_text(self, path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content.rstrip() + "\n", encoding="utf-8") + + def _write_json(self, 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 _ensure_identity(self, *, actor_id: str, actor_role: str, display_name: str) -> Dict[str, Any]: + return self.auth.register_identity( + actor_id=actor_id, + actor_role=actor_role, + password="secret123", + account_id=actor_id if actor_role in {"reader", "customer"} else None, + display_name=display_name, + )["identity"] + + def _ensure_launch_candidate(self, *, account_id: str) -> Dict[str, Any]: + self.customer_accounts.ensure_customer_account( + account_id=account_id, + display_name="Launch Candidate Wave 1", + plan_id="creator_pass", + status="active", + ) + self.customer_accounts.upsert_billing_profile( + customer_account_id=f"cust_{account_id}", + account_id=account_id, + provider="internal_preview", + invoice_email="launch-ops@example.test", + legal_name="Launch Candidate Ltd", + billing_country="GB", + tax_status="pending", + ) + self.partner_readiness.upsert_partner( + { + "partner_id": "partner_launch_candidate", + "name": "Launch Candidate Partner", + "lifecycle_status": "active", + "sla_status": "gold", + "receipt_capability": "ready", + "disclosure_readiness": "ready", + "billing_readiness": "ready", + "allowlisted_channels": ["email"], + "endpoint_health_status": "healthy", + "capabilities": [], + } + ) + self.customer_campaigns.create_or_update_campaign( + account_id=account_id, + payload={ + "title": "Launch Candidate Campaign", + "target_icp_vertical": "B2B SaaS", + "cta_text": "book demo", + "disclosure_text": "Sponsored production pilot outreach.", + "selected_channels": ["email"], + "selected_partner_refs": ["partner_launch_candidate"], + "proof_points": ["validated handoff", "validated conversion"], + "proof_source_urls": ["https://example.test/proof/launch"], + "proof_artifact_refs": ["artifact://launch-proof"], + "activation_status": "active", + }, + ) + self.repository.save_quality_event( + { + "event_id": f"quality_event_launch_{account_id}", + "trace_id": f"trace_launch_{account_id}", + "event_type": "chapter_quality_evaluated", + "source_surface": "reader", + "status": "passed", + "world_version_id": "urban_mystery_lotus_lane@0.1.0", + "session_id": f"session_launch_{account_id}", + "source_ref": {"kind": "chapter", "account_id": account_id}, + "payload": {"reason_codes": ["supported"]}, + } + ) + self.observability.record_runtime_receipt( + surface="reader", + action="continue_story", + response_status="ok", + world_id="urban_mystery_lotus_lane", + world_version_id="urban_mystery_lotus_lane@0.1.0", + session_id=f"session_launch_{account_id}", + account_id=account_id, + reader_id=account_id, + candidate_batch={"debug": {}}, + rendered_scene={"debug": {}}, + reader_view={"body": "Launch execution prep canonical reader output."}, + estimated_cost=0.01, + runtime_latency_ms=10.0, + trace_id=f"trace_launch_{account_id}", + quality_event_id=f"quality_event_launch_{account_id}", + ) + self.repository.save_quality_feedback_item( + { + "feedback_item_id": f"feedback_launch_{account_id}", + "feedback_type": "explicit_user_feedback", + "signal": "explicit_positive", + "source_surface": "reader", + "trace_id": f"trace_launch_{account_id}", + "account_id": account_id, + "world_version_id": "urban_mystery_lotus_lane@0.1.0", + "session_id": f"session_launch_{account_id}", + "source_ref": {"kind": "session", "account_id": account_id}, + "payload": {"reason_code": "useful"}, + } + ) + preview = self.commercial_billing.invoice_preview(account_id=account_id) + return { + "account_id": account_id, + "customer_account": self.customer_accounts.customer_account_detail(account_id=account_id)["customer_account"], + "invoice_preview": preview, + } + + def _ensure_signoff(self, *, actor_id: str, actor_role: str, launch_label: str, due_in_days: int) -> Dict[str, Any]: + current = self.production_signoff.current_signoff_summary() + if current and current.get("signoff_id"): + return self.production_signoff.signoff_detail(signoff_id=current["signoff_id"]) + created = self.production_signoff.initialize_signoff_run( + actor_id=actor_id, + actor_role=actor_role, + launch_label=launch_label, + due_in_days=due_in_days, + ) + return self.production_signoff.signoff_detail(signoff_id=created["signoff"]["signoff_id"]) + + def _maybe_attach_manual_prep(self, *, actor_id: str, actor_role: str, detail: Dict[str, Any]) -> Dict[str, Any]: + evidence_rows = list(detail.get("evidence") or []) + by_item_id: Dict[str, List[Dict[str, Any]]] = {} + for row in evidence_rows: + by_item_id.setdefault(str(row.get("signoff_item_id") or ""), []).append(row) + prepared_items: List[Dict[str, Any]] = [] + for item in list(detail.get("items") or []): + item_code = str(item.get("item_code") or "") + if item_code not in OWNER_ASSIGNMENTS: + continue + assignment = OWNER_ASSIGNMENTS[item_code] + if str(item.get("owner_actor_id") or "") != assignment["owner_actor_id"]: + updated = self.production_signoff.assign_signoff_item_owner( + actor_id=actor_id, + actor_role=actor_role, + signoff_item_id=item["signoff_item_id"], + owner_actor_id=assignment["owner_actor_id"], + ) + item = updated["item"] + evidence_spec = MANUAL_EVIDENCE_MAP[item_code] + existing_summaries = {str(row.get("summary") or "") for row in by_item_id.get(str(item["signoff_item_id"]), [])} + if evidence_spec["summary"] not in existing_summaries: + self.production_signoff.append_signoff_evidence( + actor_id=actor_id, + actor_role=actor_role, + signoff_item_id=item["signoff_item_id"], + evidence_type="artifact_ref", + summary=evidence_spec["summary"], + source_ref={"paths": evidence_spec["paths"]}, + payload={"note": evidence_spec["note"], "paths": evidence_spec["paths"]}, + customer_safe=False, + ) + if str(item.get("status") or "") == "pending": + self.production_signoff.decide_signoff_item( + actor_id=actor_id, + actor_role=actor_role, + signoff_item_id=item["signoff_item_id"], + decision="ready_for_review", + note="launch_execution_prep_attached_current_evidence_awaiting_human_confirmation", + ) + prepared_items.append({"item_code": item_code, "owner_actor_id": assignment["owner_actor_id"]}) + refreshed = self.production_signoff.signoff_detail(signoff_id=detail["signoff"]["signoff_id"]) + return {"detail": refreshed, "prepared_items": prepared_items} + + def _ensure_cutover_window(self, *, actor_id: str, actor_role: str, detail: Dict[str, Any], launch_wave: str) -> Dict[str, Any]: + existing = list(detail.get("cutover_windows") or []) + if existing: + return existing[0] + starts_at = (datetime.now(timezone.utc) + timedelta(days=1)).replace(minute=0, second=0, microsecond=0).isoformat() + ends_at = (datetime.now(timezone.utc) + timedelta(days=1, hours=2)).replace(minute=0, second=0, microsecond=0).isoformat() + window = self.production_signoff.mark_cutover_window( + actor_id=actor_id, + actor_role=actor_role, + signoff_id=detail["signoff"]["signoff_id"], + launch_wave=launch_wave, + target_environment="production", + starts_at=starts_at, + ends_at=ends_at, + rollback_owner_role="db_owner", + status="planned", + payload={"source": "launch_execution_prep", "cutover_pack": "artifacts/production_cutover_pack/latest/summary.json"}, + ) + return window["cutover_window"] + + def _prep_report(self, summary: Dict[str, Any]) -> str: + signoff = summary["signoff"]["signoff"] + acceptance = summary["acceptance"]["acceptance_record"] + wave = summary["acceptance"]["launch_wave_status"] + lines = [ + "# Launch Execution Prep", + "", + f"- generated_at: {summary['generated_at']}", + f"- account_id: {summary['seeded_launch_customer']['account_id']}", + f"- signoff_id: {signoff['signoff_id']}", + f"- signoff_status: {signoff['status']}", + f"- acceptance_record_id: {acceptance['acceptance_record_id']}", + f"- acceptance_status: {acceptance['status']}", + f"- launch_wave: {wave['launch_wave']}", + f"- launch_wave_status: {wave['status']}", + f"- handshake_bundle: {summary['handshake_pack']['bundle_id']}", + "", + "## Manual Signoff Prep", + ] + for item in summary["prepared_items"]: + lines.append(f"- {item['item_code']} -> {item['owner_actor_id']}") + lines.extend( + [ + "", + "## Notes", + "- Manual items were moved to `ready_for_review` when evidence was attached.", + "- No manual item was auto-approved.", + "- Launch acceptance may remain blocked until human signoff completes.", + ] + ) + return "\n".join(lines) + + def run( + self, + *, + actor_id: str = "ops_launch_executor", + actor_role: str = "reviewer", + launch_label: str = "production_launch_prep", + account_id: str = "acct_launch_customer_wave1", + launch_wave: str = "wave_1", + due_in_days: int = 2, + output_root: str | Path | None = None, + ) -> Dict[str, Any]: + self._ensure_identity(actor_id=actor_id, actor_role=actor_role, display_name="Launch Executor") + for assignment in OWNER_ASSIGNMENTS.values(): + self._ensure_identity( + actor_id=assignment["owner_actor_id"], + actor_role=assignment["actor_role"], + display_name=assignment["display_name"], + ) + + seeded_launch_customer = self._ensure_launch_candidate(account_id=account_id) + signoff_detail = self._ensure_signoff( + actor_id=actor_id, + actor_role=actor_role, + launch_label=launch_label, + due_in_days=due_in_days, + ) + cutover_window = self._ensure_cutover_window( + actor_id=actor_id, + actor_role=actor_role, + detail=signoff_detail, + launch_wave=launch_wave, + ) + + acceptance = self.production_acceptance.generate_acceptance_record( + actor_id=actor_id, + actor_role=actor_role, + account_id=account_id, + launch_wave=launch_wave, + signoff_id=signoff_detail["signoff"]["signoff_id"], + ) + self.production_acceptance.update_launch_wave_status( + actor_id=actor_id, + actor_role=actor_role, + launch_wave=launch_wave, + status=str(acceptance["launch_wave_status"].get("status") or "blocked"), + note="launch execution prep seeded; awaiting human production signoff", + ) + launch_week_pack = self.production_launch_week_pack.build_pack() + prepared = self._maybe_attach_manual_prep(actor_id=actor_id, actor_role=actor_role, detail=signoff_detail) + signoff_detail = prepared["detail"] + signoff_export = self.production_signoff.export_signoff_record(signoff_id=signoff_detail["signoff"]["signoff_id"]) + handshake_pack = self.production_handshake_pack.build_pack() + + summary = { + "generated_at": self._utcnow(), + "seeded_launch_customer": seeded_launch_customer, + "signoff": self.production_signoff.signoff_detail(signoff_id=signoff_detail["signoff"]["signoff_id"]), + "prepared_items": prepared["prepared_items"], + "cutover_window": cutover_window, + "acceptance": acceptance, + "launch_week_pack": launch_week_pack, + "signoff_export": signoff_export, + "handshake_pack": handshake_pack, + } + + run_id = f"launch_execution_prep_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}" + bundle_dir = Path(output_root) if output_root else (self._artifacts_root() / "launch_execution_prep" / run_id) + bundle_dir.mkdir(parents=True, exist_ok=True) + self._write_json(bundle_dir / "summary.json", summary) + self._write_text(bundle_dir / "report.md", self._prep_report(summary)) + latest_dir = self._artifacts_root() / "launch_execution_prep" / "latest" + if latest_dir.exists(): + shutil.rmtree(latest_dir) + shutil.copytree(bundle_dir, latest_dir) + return { + "run_id": run_id, + "bundle_dir": str(bundle_dir), + "latest_dir": str(latest_dir), + **summary, + } diff --git a/src/narrativeos/services/launch_week_guard.py b/src/narrativeos/services/launch_week_guard.py new file mode 100644 index 0000000..6a5ed88 --- /dev/null +++ b/src/narrativeos/services/launch_week_guard.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import json +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from .customer_success_reporting import CustomerSuccessReportingService +from .launch_command_center import LaunchCommandCenterService +from .production_launch_ledger import ProductionLaunchLedgerService +from ..persistence.repositories import SQLAlchemyPlatformRepository + + +ROOT = Path(__file__).resolve().parents[3] + + +class LaunchWeekGuardService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + customer_success_reporting_service: CustomerSuccessReportingService, + launch_command_center_service: LaunchCommandCenterService, + production_launch_ledger_service: ProductionLaunchLedgerService, + base_dir: Optional[Path] = None, + ) -> None: + self.repository = repository + self.customer_success = customer_success_reporting_service + self.launch_command_center = launch_command_center_service + self.launch_ledger = production_launch_ledger_service + self.base_dir = Path(base_dir or ROOT) + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _artifacts_root(self) -> Path: + return self.base_dir / "artifacts" + + def _write_text(self, path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content.rstrip() + "\n", encoding="utf-8") + + def _write_json(self, 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 _high_support_urgency(self, *, account_id: str) -> int: + cases = self.repository.list_support_cases(account_id=account_id, limit=100) + return len([item for item in cases if str(item.get("status") or "") in {"open", "in_progress"} and str(item.get("priority") or "") == "high"]) + + def _open_disputes(self, *, account_id: str) -> int: + disputes = self.repository.list_disputes(account_id=account_id, limit=100) + return len([item for item in disputes if str(item.get("status") or "") in {"open", "under_review", "approved"}]) + + def sync(self, *, actor_id: str, actor_role: str, launch_wave: str = "wave_1") -> Dict[str, Any]: + success_listing = self.customer_success.list_customer_success(launch_wave=launch_wave, limit=50) + account = next(iter(success_listing.get("accounts") or []), None) + account_id = (account or {}).get("account_id") + center = self.launch_command_center.command_center(launch_wave=launch_wave) + critical_alerts = [ + item + for panel_key in ( + "billing_anomaly_panel", + "checkout_failure_panel", + "reader_generation_panel", + "quality_block_panel", + "vercel_runtime_panel", + "support_urgency_panel", + "dispute_anomaly_panel", + "dunning_anomaly_panel", + "webhook_anomaly_panel", + ) + for item in list((center.get(panel_key) or {}).get("alerts") or []) + if str(item.get("severity") or "") == "critical" + ] + latest_score = (account or {}).get("pilot_to_paid_readiness_score") or {} + open_disputes = self._open_disputes(account_id=account_id) if account_id else 0 + high_support = self._high_support_urgency(account_id=account_id) if account_id else 0 + summaries = { + "day1": { + "launch_wave": launch_wave, + "critical_alert_count": len(critical_alerts), + "watchlist_count": center.get("summary", {}).get("watchlist_count", 0), + }, + "day3": { + "launch_wave": launch_wave, + "customer_success_band": latest_score.get("band"), + "launch_event_count": self.launch_ledger.list_events(launch_wave=launch_wave).get("summary", {}).get("event_count", 0), + }, + "day7": { + "launch_wave": launch_wave, + "open_disputes": open_disputes, + "high_support_urgency": high_support, + "critical_alert_count": len(critical_alerts), + }, + } + criteria = { + "day_summaries_exist": all(bool(value) for value in summaries.values()), + "no_unresolved_critical_alert": len(critical_alerts) == 0, + "latest_customer_success_band_ready": str(latest_score.get("band") or "") == "ready", + "no_open_dispute_backlog": open_disputes == 0, + "no_open_support_urgency_high": high_support == 0, + "launch_week_expansion_guard_clear": int(center.get("summary", {}).get("launch_week_expansion_guard_alert_count") or 0) == 0, + } + replication_readiness = "ready" if all(criteria.values()) else "not_ready" + run = self.repository.save_launch_week_guard_run( + { + "launch_wave": launch_wave, + "account_id": account_id, + "status": "ready" if replication_readiness == "ready" else "not_ready", + "replication_readiness": replication_readiness, + "summary": { + "criteria": criteria, + "day_summaries": summaries, + "critical_alert_count": len(critical_alerts), + }, + } + ) + pack = self.repository.save_first_customer_success_pack( + { + "launch_wave": launch_wave, + "account_id": account_id, + "status": replication_readiness, + "pack_payload": { + "run_id": run["launch_week_guard_run_id"], + "criteria": criteria, + "day_summaries": summaries, + }, + } + ) + artifact_refs = self._build_pack(run=run, pack=pack, account_id=account_id, summaries=summaries, criteria=criteria) + pack = self.repository.save_first_customer_success_pack( + { + **pack, + "pack_payload_json": { + **dict(pack.get("pack_payload_json") or {}), + "artifact_refs": artifact_refs, + }, + } + ) + return { + "launch_week_guard_run": run, + "first_customer_success_pack": pack, + "artifact_refs": artifact_refs, + } + + def _build_pack( + self, + *, + run: Dict[str, Any], + pack: Dict[str, Any], + account_id: Optional[str], + summaries: Dict[str, Dict[str, Any]], + criteria: Dict[str, bool], + ) -> Dict[str, Any]: + run_id = f"first_customer_success_pack_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}" + bundle_dir = self._artifacts_root() / "first_customer_success_pack" / run_id + bundle_dir.mkdir(parents=True, exist_ok=True) + for key, payload in summaries.items(): + self._write_text( + bundle_dir / f"{key}_summary.md", + "\n".join([f"# {key.upper()} Summary", "", json.dumps(payload, ensure_ascii=False, indent=2)]), + ) + success_lines = [ + "# First Customer Success Pack", + "", + f"- launch_wave: {run['launch_wave']}", + f"- account_id: {account_id or '-'}", + f"- replication_readiness: {run['replication_readiness']}", + f"- criteria: {criteria}", + ] + self._write_text(bundle_dir / "first_customer_success_pack.md", "\n".join(success_lines)) + self._write_text( + bundle_dir / "wave_stabilization_checklist.md", + "\n".join( + [ + "# Wave Stabilization Checklist", + "", + f"- no unresolved critical alert: {criteria['no_unresolved_critical_alert']}", + f"- latest customer success band ready: {criteria['latest_customer_success_band_ready']}", + f"- no open dispute backlog: {criteria['no_open_dispute_backlog']}", + f"- no high support urgency: {criteria['no_open_support_urgency_high']}", + ] + ), + ) + self._write_text( + bundle_dir / "replication_readiness_assessment.md", + "\n".join( + [ + "# Replication Readiness Assessment", + "", + f"- verdict: {run['replication_readiness']}", + f"- criteria: {criteria}", + ] + ), + ) + self._write_json(bundle_dir / "summary.json", {"run": run, "pack": pack, "criteria": criteria, "summaries": summaries}) + latest_dir = self._artifacts_root() / "first_customer_success_pack" / "latest" + if latest_dir.exists(): + shutil.rmtree(latest_dir) + shutil.copytree(bundle_dir, latest_dir) + return { + "artifact_dir": str(bundle_dir), + "summary_json": str(bundle_dir / "summary.json"), + "day1_summary_md": str(bundle_dir / "day1_summary.md"), + "day3_summary_md": str(bundle_dir / "day3_summary.md"), + "day7_summary_md": str(bundle_dir / "day7_summary.md"), + "first_customer_success_pack_md": str(bundle_dir / "first_customer_success_pack.md"), + "wave_stabilization_checklist_md": str(bundle_dir / "wave_stabilization_checklist.md"), + "replication_readiness_assessment_md": str(bundle_dir / "replication_readiness_assessment.md"), + } + + def list_runs(self, *, launch_wave: Optional[str] = None) -> Dict[str, Any]: + runs = self.repository.list_launch_week_guard_runs(launch_wave=launch_wave, limit=50) + current = runs[0] if runs else None + return { + "runs": runs, + "current_run": current, + "summary": { + "run_count": len(runs), + "latest_run_id": (current or {}).get("launch_week_guard_run_id"), + "ready_count": len([item for item in runs if str(item.get("replication_readiness") or "") == "ready"]), + }, + } + + def detail(self, *, launch_wave: str) -> Dict[str, Any]: + run = next(iter(self.repository.list_launch_week_guard_runs(launch_wave=launch_wave, limit=1)), None) + pack = next(iter(self.repository.list_first_customer_success_packs(launch_wave=launch_wave, limit=1)), None) + return { + "launch_week_guard_run": run, + "first_customer_success_pack": pack, + "artifact_refs": dict((pack or {}).get("pack_payload_json") or {}).get("artifact_refs", {}), + } diff --git a/src/narrativeos/services/launch_week_monitoring.py b/src/narrativeos/services/launch_week_monitoring.py new file mode 100644 index 0000000..c9d85e5 --- /dev/null +++ b/src/narrativeos/services/launch_week_monitoring.py @@ -0,0 +1,726 @@ +from __future__ import annotations + +import json +import os +import re +import shutil +import time +import urllib.error +import urllib.request +from collections import Counter +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Sequence + +from .async_jobs import AsyncJobService +from .observability import ObservabilityService +from .ops_quality_projection import OpsQualityProjectionService +from .reader_generation_jobs import READER_GENERATION_JOB_TYPE +from ..persistence.repositories import SQLAlchemyPlatformRepository + + +ROOT = Path(__file__).resolve().parents[3] +SCHEMA_VERSION = "launch_week_monitoring/v1" +DEFAULT_PUBLIC_APP_URL = "https://pilot.lixidol.com" +DEFAULT_PROBE_ENDPOINTS = [ + "/health", + "/api/v1/health", + "/api/v1/story/import/public-works", + "/showcase", + "/story", +] +PILOT_ACCOUNT_STATUSES = {"trial", "active", "paused", "renewal_due"} +PILOT_TRACK_STATUSES = {"watch", "ready_for_conversion", "converted"} +SECRET_PATTERNS = [ + re.compile(r"Bearer\s+[A-Za-z0-9._~+/=-]{8,}", re.IGNORECASE), + re.compile(r"\b(?:postgres(?:ql)?|mysql|sqlite)://[^\s\"']+", re.IGNORECASE), + re.compile(r"\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9_]{8,}\b"), + re.compile(r"\bwhsec_[A-Za-z0-9_]{8,}\b"), + re.compile(r"\bvercel_[A-Za-z0-9]{20,}\b", re.IGNORECASE), + re.compile(r"\bVERCEL_TOKEN\b", re.IGNORECASE), + re.compile(r"\b(?:DATABASE_URL|COOKIE|SET_COOKIE|ACCESS_TOKEN|REFRESH_TOKEN)\b", re.IGNORECASE), +] + + +class LaunchWeekMonitoringService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + observability_service: ObservabilityService, + async_job_service: Optional[AsyncJobService] = None, + quality_projection_service: Optional[OpsQualityProjectionService] = None, + base_dir: Optional[Path] = None, + ) -> None: + self.repository = repository + self.observability = observability_service + self.async_jobs = async_job_service + self.quality_projection = quality_projection_service + self.base_dir = Path(base_dir or ROOT) + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _parse_timestamp(self, value: Any) -> Optional[datetime]: + if not value: + return None + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + def _within_window(self, item: Dict[str, Any], *, since: datetime, keys: Sequence[str]) -> bool: + for key in keys: + parsed = self._parse_timestamp(item.get(key)) + if parsed is not None and parsed >= since: + return True + return False + + def _threshold(self, env_key: str, default: int) -> int: + try: + return max(0, int(os.getenv(env_key, str(default)))) + except ValueError: + return default + + def _safe_float(self, value: Any) -> Optional[float]: + try: + if value is None: + return None + return float(value) + except (TypeError, ValueError): + return None + + def _percentile(self, values: Sequence[float], percentile: 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, percentile)) * 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 _redact_ref(self, value: Any) -> str: + raw = str(value or "").strip() + if not raw: + return "" + if len(raw) <= 10: + return f"{raw[:3]}…" + return f"{raw[:6]}…{raw[-4:]}" + + def _redact_refs(self, refs: Iterable[Dict[str, Any]]) -> List[Dict[str, str]]: + redacted = [] + for item in refs: + kind = str(item.get("kind") or "ref") + value = str(item.get("id") or "").strip() + if not value: + continue + redacted.append({"kind": kind, "id_ref": self._redact_ref(value)}) + return redacted + + def _is_smoke_or_test_account(self, item: Dict[str, Any]) -> bool: + metadata = dict(item.get("metadata_json") or item.get("track_payload_json") or {}) + account_id = str(item.get("account_id") or "").lower() + display_name = str(item.get("display_name") or "").lower() + values = " ".join( + [ + account_id, + display_name, + json.dumps(metadata, ensure_ascii=False, default=str).lower(), + ] + ) + smoke_markers = { + "smoke", + "smoke_run", + "remote_smoke", + "ci_headless", + "test_account", + "qa_account", + } + return any(marker in values for marker in smoke_markers) + + def resolve_invited_pilot_cohort(self, *, limit: int = 1000) -> Dict[str, Any]: + customers = self.repository.list_customer_accounts(limit=limit) + tracks = self.repository.list_pilot_conversion_tracks(limit=limit) + account_ids: set[str] = set() + source_counts = {"customer_account": 0, "pilot_conversion_track": 0} + excluded_smoke_count = 0 + + for item in customers: + if self._is_smoke_or_test_account(item): + excluded_smoke_count += 1 + continue + if str(item.get("status") or "") in PILOT_ACCOUNT_STATUSES and str(item.get("account_id") or "").strip(): + account_ids.add(str(item["account_id"])) + source_counts["customer_account"] += 1 + + for item in tracks: + if self._is_smoke_or_test_account(item): + excluded_smoke_count += 1 + continue + if str(item.get("status") or "") in PILOT_TRACK_STATUSES and str(item.get("account_id") or "").strip(): + account_ids.add(str(item["account_id"])) + source_counts["pilot_conversion_track"] += 1 + + sorted_ids = sorted(account_ids) + return { + "account_ids": sorted_ids, + "account_refs": [{"kind": "account", "id": item} for item in sorted_ids], + "account_count": len(sorted_ids), + "source_counts": source_counts, + "excluded_smoke_count": excluded_smoke_count, + } + + def _signal_payload( + self, + *, + signal_key: str, + count: int, + threshold: int, + refs: Optional[List[Dict[str, Any]]] = None, + details: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + breached = count > threshold + return { + "signal_key": signal_key, + "count": count, + "threshold": threshold, + "status": "alert" if breached else "ok", + "breached": breached, + "refs": list(refs or []), + "details": dict(details or {}), + } + + def _runtime_receipts_for_accounts( + self, + *, + account_ids: set[str], + since: datetime, + limit: int, + ) -> List[Dict[str, Any]]: + receipts: List[Dict[str, Any]] = [] + for account_id in sorted(account_ids): + for item in self.observability.list_runtime_receipts(account_id=account_id, incident_only=True, limit=limit): + if not self._within_window(item, since=since, keys=("occurred_at", "generated_at")): + continue + surface = str(item.get("surface") or "").lower() + action = str(item.get("action") or "").lower() + if surface in {"reader", "story", "reader_shell"} or any( + marker in action for marker in ("reader", "story", "continue", "choice", "import") + ): + receipts.append(item) + return receipts + + def _remote_generation_signal(self, *, account_ids: set[str], since: datetime) -> Dict[str, Any]: + refs: List[Dict[str, Any]] = [] + by_reason: Dict[str, int] = {} + failed_jobs: List[Dict[str, Any]] = [] + stale_jobs: List[Dict[str, Any]] = [] + if self.async_jobs is not None: + for job in self.async_jobs.list_jobs(job_type=READER_GENERATION_JOB_TYPE, limit=1000): + if str(job.get("account_id") or "") not in account_ids: + continue + if not self._within_window(job, since=since, keys=("finished_at", "updated_at", "created_at", "started_at")): + continue + status = str(job.get("status") or "") + if status == "failed": + failed_jobs.append(job) + by_reason["async_job_failed"] = by_reason.get("async_job_failed", 0) + 1 + refs.append({"kind": "account", "id": job.get("account_id")}) + refs.append({"kind": "async_job", "id": job.get("job_id")}) + elif status == "running" and str(job.get("lease_status") or "") in {"expired", "missing"}: + stale_jobs.append(job) + by_reason["async_job_stale_running"] = by_reason.get("async_job_stale_running", 0) + 1 + refs.append({"kind": "account", "id": job.get("account_id")}) + refs.append({"kind": "async_job", "id": job.get("job_id")}) + receipts = self._runtime_receipts_for_accounts(account_ids=account_ids, since=since, limit=100) + for receipt in receipts: + for flag in list(receipt.get("incident_flags") or []): + by_reason[str(flag)] = by_reason.get(str(flag), 0) + 1 + refs.append({"kind": "account", "id": receipt.get("account_id") or receipt.get("reader_id")}) + refs.append({"kind": "runtime_receipt", "id": receipt.get("event_id")}) + + count = len(failed_jobs) + len(stale_jobs) + len(receipts) + return self._signal_payload( + signal_key="remote_generation_failures", + count=count, + threshold=self._threshold("NARRATIVEOS_LAUNCH_ALERT_REMOTE_GENERATION_FAILURE_THRESHOLD", 0), + refs=refs, + details={ + "failed_job_count": len(failed_jobs), + "stale_running_job_count": len(stale_jobs), + "runtime_incident_count": len(receipts), + "by_reason": by_reason, + }, + ) + + def _checkout_signal(self, *, account_ids: set[str], since: datetime) -> Dict[str, Any]: + failed_statuses = {"failed", "expired", "canceled", "cancelled", "blocked", "error"} + successful_statuses = {"fulfilled", "completed", "complete", "paid", "active"} + now = datetime.now(timezone.utc) + checkout_failures = [] + for item in self.repository.list_billing_checkout_sessions(limit=1000): + if str(item.get("account_id") or "") not in account_ids: + continue + if not self._within_window(item, since=since, keys=("updated_at", "created_at", "expires_at")): + continue + status = str(item.get("status") or "").lower() + expires_at = self._parse_timestamp(item.get("expires_at")) + if status in failed_statuses or (expires_at is not None and expires_at <= now and status not in successful_statuses): + checkout_failures.append(item) + + payment_failures = [ + item + for item in self.repository.list_payment_transactions(status="failed", limit=1000) + if str(item.get("account_id") or "") in account_ids + and self._within_window(item, since=since, keys=("occurred_at", "created_at")) + ] + refs = [ + *[{"kind": "account", "id": item.get("account_id")} for item in checkout_failures + payment_failures], + *[{"kind": "checkout_session", "id": item.get("checkout_session_id")} for item in checkout_failures], + *[{"kind": "payment_transaction", "id": item.get("payment_transaction_id")} for item in payment_failures], + ] + return self._signal_payload( + signal_key="checkout_failures", + count=len(checkout_failures) + len(payment_failures), + threshold=self._threshold("NARRATIVEOS_LAUNCH_ALERT_CHECKOUT_FAILURE_THRESHOLD", 0), + refs=refs, + details={ + "checkout_session_failure_count": len(checkout_failures), + "payment_transaction_failure_count": len(payment_failures), + "checkout_status_counts": dict(Counter(str(item.get("status") or "unknown") for item in checkout_failures)), + }, + ) + + def _support_signal(self, *, account_ids: set[str], since: datetime) -> Dict[str, Any]: + open_cases = [ + item + for item in self.repository.list_support_cases(limit=1000) + if str(item.get("account_id") or "") in account_ids + and str(item.get("status") or "") in {"open", "in_progress"} + and self._within_window(item, since=since, keys=("updated_at", "created_at")) + ] + high_cases = [item for item in open_cases if str(item.get("priority") or "").lower() == "high"] + refs = [ + *[{"kind": "account", "id": item.get("account_id")} for item in high_cases or open_cases], + *[{"kind": "support_case", "id": item.get("support_case_id")} for item in high_cases or open_cases], + ] + return self._signal_payload( + signal_key="support_cases", + count=len(high_cases), + threshold=self._threshold("NARRATIVEOS_LAUNCH_ALERT_HIGH_SUPPORT_CASE_THRESHOLD", 0), + refs=refs, + details={ + "open_or_in_progress_count": len(open_cases), + "high_priority_count": len(high_cases), + "priority_counts": dict(Counter(str(item.get("priority") or "unknown") for item in open_cases)), + }, + ) + + def _quality_signal(self, *, account_ids: set[str], since: datetime) -> Dict[str, Any]: + blocked: List[Dict[str, Any]] = [] + review_required: List[Dict[str, Any]] = [] + if self.quality_projection is not None: + for account_id in sorted(account_ids): + events = self.quality_projection.list_projected_quality_events(account_id=account_id, limit=200) + for event in events: + if not self._within_window(event, since=since, keys=("created_at",)): + continue + status = str(event.get("status") or "") + if status == "blocked": + blocked.append(event) + elif status == "review_required": + review_required.append(event) + else: + events = self.repository.list_quality_events(limit=1000) + for event in events: + if not self._within_window(event, since=since, keys=("created_at",)): + continue + source_ref = dict(event.get("source_ref") or {}) + if str(source_ref.get("account_id") or "") not in account_ids: + continue + status = str(event.get("status") or "") + if status == "blocked": + blocked.append(event) + elif status == "review_required": + review_required.append(event) + + refs = [ + *[{"kind": "account", "id": item.get("account_id")} for item in blocked], + *[{"kind": "quality_event", "id": item.get("event_id")} for item in blocked], + ] + return self._signal_payload( + signal_key="quality_blocks", + count=len(blocked), + threshold=self._threshold("NARRATIVEOS_LAUNCH_ALERT_QUALITY_BLOCK_THRESHOLD", 0), + refs=refs, + details={ + "blocked_count": len(blocked), + "review_required_count": len(review_required), + "reason_counts": dict(Counter(reason for item in blocked + review_required for reason in list(item.get("reason_codes") or []))), + }, + ) + + def _load_performance_summary(self, performance_summary_path: Optional[str]) -> Dict[str, Any]: + path = Path(performance_summary_path or self.base_dir / "artifacts" / "vercel_remote_acceptance" / "latest" / "performance.json") + if not path.is_absolute(): + path = self.base_dir / path + if not path.exists(): + return {"status": "missing", "measurements": [], "cold_start_5xx_count": 0} + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {"status": "invalid_json", "measurements": [], "cold_start_5xx_count": 0} + + def _read_json_artifact(self, relative_path: str) -> Dict[str, Any]: + path = self.base_dir / relative_path + if not path.exists(): + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + return payload if isinstance(payload, dict) else {} + + def _resolve_deployment_id(self, explicit: Optional[str]) -> Optional[str]: + if explicit: + return explicit + env_value = str(os.getenv("VERCEL_DEPLOYMENT_ID") or os.getenv("DEPLOYMENT_ID") or "").strip() + if env_value: + return env_value + paid_acceptance = self._read_json_artifact("artifacts/paid_pilot_acceptance/latest/summary.json") + remote_acceptance = dict(paid_acceptance.get("remote_vercel_acceptance") or {}) + paid_deployment_id = str(remote_acceptance.get("vercel_deployment_id") or "").strip() + if paid_deployment_id: + return paid_deployment_id + domain_binding = self._read_json_artifact("artifacts/vercel_remote_acceptance/latest/domain_binding.json") + binding_deployment_id = str(domain_binding.get("deployment_id") or "").strip() + return binding_deployment_id or None + + def _url(self, base_url: str, path: str) -> str: + return base_url.rstrip("/") + "/" + path.lstrip("/") + + def _timed_get(self, url: str, timeout: float = 20.0) -> Dict[str, Any]: + started = time.perf_counter() + try: + with urllib.request.urlopen(url, timeout=timeout) as response: + response.read(256) + status = int(response.status) + except urllib.error.HTTPError as exc: + status = int(exc.code) + except (urllib.error.URLError, TimeoutError, OSError) as exc: + return { + "url": url, + "status": 0, + "ok": False, + "duration_ms": round((time.perf_counter() - started) * 1000, 3), + "error": str(exc), + } + return { + "url": url, + "status": status, + "ok": 200 <= status < 400, + "duration_ms": round((time.perf_counter() - started) * 1000, 3), + } + + def _vercel_signal( + self, + *, + public_app_url: str, + performance_summary_path: Optional[str], + run_probes: bool, + ) -> Dict[str, Any]: + performance = self._load_performance_summary(performance_summary_path) + measurements = list(performance.get("measurements") or []) + probe_measurements: List[Dict[str, Any]] = [] + if run_probes and public_app_url: + for endpoint in DEFAULT_PROBE_ENDPOINTS: + probe_measurements.append(self._timed_get(self._url(public_app_url, endpoint))) + measurements.extend(probe_measurements) + durations = [float(item["duration_ms"]) for item in measurements if self._safe_float(item.get("duration_ms")) is not None] + p95_ms = self._percentile(durations, 0.95) + five_xx_count = int(performance.get("cold_start_5xx_count") or 0) + sum( + 1 for item in probe_measurements if int(item.get("status") or 0) >= 500 + ) + failed_probe_count = len([item for item in probe_measurements if not item.get("ok")]) + p95_threshold = self._threshold("NARRATIVEOS_LAUNCH_ALERT_COLD_START_P95_MS", 3000) + five_xx_threshold = self._threshold("NARRATIVEOS_LAUNCH_ALERT_COLD_START_5XX_THRESHOLD", 0) + breached = five_xx_count > five_xx_threshold or (p95_ms is not None and p95_ms > p95_threshold) + return { + "signal_key": "vercel_cold_start_latency", + "count": five_xx_count, + "threshold": five_xx_threshold, + "status": "alert" if breached else "ok", + "breached": breached, + "refs": [], + "details": { + "public_app_url": public_app_url, + "performance_summary_status": performance.get("status"), + "measurement_count": len(measurements), + "probe_count": len(probe_measurements), + "failed_probe_count": failed_probe_count, + "cold_start_5xx_count": five_xx_count, + "cold_start_5xx_threshold": five_xx_threshold, + "p95_latency_ms": p95_ms, + "p95_latency_threshold_ms": p95_threshold, + }, + } + + def evaluate( + self, + *, + public_app_url: str = DEFAULT_PUBLIC_APP_URL, + performance_summary_path: Optional[str] = None, + window_hours: Optional[int] = None, + run_probes: bool = False, + ) -> Dict[str, Any]: + resolved_window_hours = int(window_hours or self._threshold("NARRATIVEOS_LAUNCH_WEEK_WINDOW_HOURS", 24) or 24) + since = datetime.now(timezone.utc) - timedelta(hours=max(1, resolved_window_hours)) + cohort = self.resolve_invited_pilot_cohort() + account_ids = set(cohort["account_ids"]) + signals = { + "remote_generation_failures": self._remote_generation_signal(account_ids=account_ids, since=since), + "checkout_failures": self._checkout_signal(account_ids=account_ids, since=since), + "support_cases": self._support_signal(account_ids=account_ids, since=since), + "quality_blocks": self._quality_signal(account_ids=account_ids, since=since), + "vercel_cold_start_latency": self._vercel_signal( + public_app_url=public_app_url, + performance_summary_path=performance_summary_path, + run_probes=run_probes, + ), + } + breached = [key for key, value in signals.items() if value.get("breached")] + return { + "schema_version": SCHEMA_VERSION, + "generated_at": self._utcnow(), + "public_app_url": public_app_url, + "window_hours": resolved_window_hours, + "window_start": since.isoformat(), + "gate_mode": "post_cutover_expansion_guard", + "ready_to_expand": not breached, + "status": "ready_to_expand" if not breached else "blocked", + "cohort": cohort, + "signals": signals, + "breached_signals": breached, + } + + def _alert_from_signal(self, signal: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if not signal.get("breached"): + return None + key = str(signal.get("signal_key") or "") + details = dict(signal.get("details") or {}) + config = { + "remote_generation_failures": { + "severity": "high", + "owner_role": "infra_owner", + "summary": f"Reader 远端生成失败/队列异常 {signal.get('count') or 0} 次。", + "recommended_actions": ["inspect_reader_generation_jobs", "resume_or_retry_failed_jobs", "review_runtime_receipts"], + }, + "checkout_failures": { + "severity": "high", + "owner_role": "stripe_owner", + "summary": f"试点 checkout/payment 失败 {signal.get('count') or 0} 次。", + "recommended_actions": ["inspect_checkout_sessions", "reconcile_provider_payment", "follow_up_customer"], + }, + "support_cases": { + "alert_key": "support_backlog", + "severity": "high", + "owner_role": "support_finance_owner", + "summary": f"高优先级 support case {details.get('high_priority_count') or 0} 条。", + "recommended_actions": ["assign_case_owner", "inspect_account_workspace", "update_support_case"], + }, + "quality_blocks": { + "severity": "high", + "owner_role": "quality_owner", + "summary": f"内容质量阻断 {signal.get('count') or 0} 次,需确认未持久化破损章节。", + "recommended_actions": ["inspect_quality_events", "review_quality_block_reason", "pause_world_if_repeating"], + }, + "vercel_cold_start_latency": { + "severity": "critical" if int(details.get("cold_start_5xx_count") or 0) > 0 else "high", + "owner_role": "infra_owner", + "summary": f"Vercel cold-start p95={details.get('p95_latency_ms')}ms, 5xx={details.get('cold_start_5xx_count') or 0}。", + "recommended_actions": ["inspect_vercel_deployment", "measure_remote_performance", "review_function_imports"], + }, + }.get(key) + if not config: + return None + alert_key = str(config.get("alert_key") or key) + refs = list(signal.get("refs") or []) + account_ids = [item.get("id") for item in refs if item.get("kind") == "account" and item.get("id")] + return { + "alert_key": alert_key, + "severity": config["severity"], + "owner_role": config["owner_role"], + "count": int(signal.get("count") or 0), + "summary": config["summary"], + "account_ids": account_ids, + "invoice_ids": [], + "payment_transaction_ids": [item.get("id") for item in refs if item.get("kind") == "payment_transaction" and item.get("id")], + "webhook_event_ids": [], + "support_case_ids": [item.get("id") for item in refs if item.get("kind") == "support_case" and item.get("id")], + "dispute_ids": [], + "recommended_actions": config["recommended_actions"], + "drilldown_refs": [ + {"kind": item.get("kind"), "id": item.get("id"), "label": item.get("id")} + for item in refs[:5] + if item.get("id") + ], + "source": "launch_week_monitoring", + } + + def current_alert_pack( + self, + *, + public_app_url: str = DEFAULT_PUBLIC_APP_URL, + performance_summary_path: Optional[str] = None, + ) -> Dict[str, Any]: + monitoring = self.evaluate( + public_app_url=public_app_url, + performance_summary_path=performance_summary_path, + run_probes=False, + ) + alerts = [ + alert + for alert in (self._alert_from_signal(signal) for signal in monitoring["signals"].values()) + if alert is not None + ] + alerts.sort(key=lambda item: ({"critical": 0, "high": 1, "medium": 2, "low": 3}.get(item["severity"], 4), item["alert_key"])) + return { + "generated_at": monitoring["generated_at"], + "summary": { + "ready_to_expand": monitoring["ready_to_expand"], + "alert_count": len(alerts), + "by_alert_key": dict(Counter(item["alert_key"] for item in alerts)), + "by_severity": dict(Counter(item["severity"] for item in alerts)), + "by_owner_role": dict(Counter(item["owner_role"] for item in alerts)), + }, + "alerts": alerts, + "monitoring_summary": { + "schema_version": monitoring["schema_version"], + "public_app_url": monitoring["public_app_url"], + "window_hours": monitoring["window_hours"], + "cohort_count": monitoring["cohort"]["account_count"], + "breached_signals": list(monitoring["breached_signals"]), + }, + } + + def _redacted_monitoring_summary(self, monitoring: Dict[str, Any], *, deployment_id: Optional[str]) -> Dict[str, Any]: + cohort = dict(monitoring.get("cohort") or {}) + redacted_signals = {} + for key, signal in dict(monitoring.get("signals") or {}).items(): + redacted_signals[key] = { + **{k: v for k, v in signal.items() if k != "refs"}, + "refs": self._redact_refs(list(signal.get("refs") or [])), + } + return { + **{k: v for k, v in monitoring.items() if k not in {"cohort", "signals"}}, + "vercel_deployment_id": deployment_id, + "cohort": { + **{k: v for k, v in cohort.items() if k not in {"account_ids", "account_refs"}}, + "account_refs": self._redact_refs(list(cohort.get("account_refs") or [])), + }, + "signals": redacted_signals, + } + + def secret_scan(self, payload: Dict[str, Any]) -> List[str]: + text = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str) + issues = [] + for pattern in SECRET_PATTERNS: + if pattern.search(text): + issues.append(pattern.pattern) + return issues + + def _report_markdown(self, summary: Dict[str, Any]) -> str: + lines = [ + "# Launch-Week Remote Monitoring Closure", + "", + f"- schema_version: {summary.get('schema_version')}", + f"- public_app_url: {summary.get('public_app_url')}", + f"- vercel_deployment_id: {summary.get('vercel_deployment_id') or '-'}", + f"- window_hours: {summary.get('window_hours')}", + f"- cohort_count: {dict(summary.get('cohort') or {}).get('account_count') or 0}", + f"- ready_to_expand: {summary.get('ready_to_expand')}", + f"- breached_signals: {', '.join(summary.get('breached_signals') or []) or '-'}", + "", + "| signal | status | count | threshold | details |", + "| --- | --- | --- | --- | --- |", + ] + for key, signal in dict(summary.get("signals") or {}).items(): + details = dict(signal.get("details") or {}) + detail_bits = [] + for detail_key in ( + "failed_job_count", + "runtime_incident_count", + "checkout_session_failure_count", + "payment_transaction_failure_count", + "open_or_in_progress_count", + "high_priority_count", + "blocked_count", + "review_required_count", + "cold_start_5xx_count", + "p95_latency_ms", + ): + if detail_key in details: + detail_bits.append(f"{detail_key}={details.get(detail_key)}") + lines.append( + f"| {key} | {signal.get('status')} | {signal.get('count')} | {signal.get('threshold')} | {'; '.join(detail_bits) or '-'} |" + ) + lines.extend( + [ + "", + "## Expansion Gate", + "", + "This is a post-cutover guard. A red result pauses new invites or pilot expansion, but does not invalidate the already-passed paid-pilot acceptance packet.", + ] + ) + return "\n".join(lines) + + def build_monitoring_closure( + self, + *, + public_app_url: str = DEFAULT_PUBLIC_APP_URL, + performance_summary_path: Optional[str] = None, + deployment_id: Optional[str] = None, + window_hours: Optional[int] = None, + run_probes: bool = True, + output_root: Optional[Path] = None, + ) -> Dict[str, Any]: + monitoring = self.evaluate( + public_app_url=public_app_url, + performance_summary_path=performance_summary_path, + window_hours=window_hours, + run_probes=run_probes, + ) + resolved_deployment_id = self._resolve_deployment_id(deployment_id) + summary = self._redacted_monitoring_summary(monitoring, deployment_id=resolved_deployment_id) + secret_issues = self.secret_scan(summary) + summary["secret_scan"] = {"passed": not secret_issues, "issue_count": len(secret_issues), "issues": secret_issues} + if secret_issues: + raise RuntimeError(f"launch_week_monitoring_secret_scan_failed:{','.join(secret_issues)}") + + run_id = f"launch_week_monitoring_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}" + bundle_dir = Path(output_root) if output_root else self.base_dir / "artifacts" / "launch_week_monitoring" / run_id + bundle_dir.mkdir(parents=True, exist_ok=True) + summary_path = bundle_dir / "summary.json" + report_path = bundle_dir / "report.md" + summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") + report_path.write_text(self._report_markdown(summary).rstrip() + "\n", encoding="utf-8") + + latest_dir = self.base_dir / "artifacts" / "launch_week_monitoring" / "latest" + if latest_dir.exists(): + shutil.rmtree(latest_dir) + shutil.copytree(bundle_dir, latest_dir) + return { + "status": summary["status"], + "ready_to_expand": bool(summary["ready_to_expand"]), + "bundle_dir": str(bundle_dir), + "latest_dir": str(latest_dir), + "summary_json": str(summary_path), + "report_md": str(report_path), + "summary": summary, + } diff --git a/src/narrativeos/services/library_stats_cube.py b/src/narrativeos/services/library_stats_cube.py new file mode 100644 index 0000000..bee8a96 --- /dev/null +++ b/src/narrativeos/services/library_stats_cube.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from ..persistence.repositories import SQLAlchemyPlatformRepository +from .library_stats_semantic_layer import ( + LIBRARY_STATS_SEMANTIC_VERSION, + LibraryStatsSemanticLayerService, +) + + +ZERO_LIBRARY_STATS = { + "totalPlayTime": 0, + "totalBranches": 0, + "worldFragments": 0, + "totalFragments": 0, +} + + +def _parse_timestamp(value: Optional[str]) -> datetime: + normalized = str(value or "").strip() + if not normalized: + return datetime.fromtimestamp(0, tz=timezone.utc) + try: + parsed = datetime.fromisoformat(normalized.replace("Z", "+00:00")) + except ValueError: + return datetime.fromtimestamp(0, tz=timezone.utc) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +class LibraryStatsCubeService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + semantic_layer_service: LibraryStatsSemanticLayerService, + ) -> None: + self.repository = repository + self.semantic = semantic_layer_service + + def build_snapshot(self, *, account_id: str) -> Dict[str, Any]: + return dict(self.semantic.build_snapshot_payload(account_id=account_id).get("snapshot_payload") or ZERO_LIBRARY_STATS) + + def source_updated_at(self, *, account_id: str) -> str: + return self.semantic.source_updated_at(account_id=account_id) + + def invalidate_account( + self, + *, + account_id: str, + event_name: str, + occurred_at: Optional[str] = None, + ) -> Dict[str, Any]: + return self.repository.invalidate_library_stats_cube( + account_id=account_id, + event_name=event_name, + occurred_at=occurred_at, + ) + + def is_stale(self, cube: Dict[str, Any], *, source_updated_at: str) -> bool: + if not cube: + return True + if str(cube.get("semantic_version") or "") != LIBRARY_STATS_SEMANTIC_VERSION: + return True + if str(cube.get("invalidated_at") or "").strip(): + return True + cube_source_updated_at = str(cube.get("source_updated_at") or "") + return _parse_timestamp(cube_source_updated_at) < _parse_timestamp(source_updated_at) + + def sync_account(self, *, account_id: str, source_updated_at: Optional[str] = None) -> Dict[str, Any]: + semantic_payload = self.semantic.build_snapshot_payload(account_id=account_id) + resolved_source_updated_at = source_updated_at or str(semantic_payload.get("source_updated_at") or "") + cube = self.repository.save_library_stats_cube( + { + "account_id": account_id, + "semantic_version": str(semantic_payload.get("semantic_version") or LIBRARY_STATS_SEMANTIC_VERSION), + "snapshot_payload": semantic_payload.get("snapshot_payload") or ZERO_LIBRARY_STATS, + "source_breakdown": semantic_payload.get("source_breakdown") or {}, + "source_updated_at": resolved_source_updated_at, + "invalidated_at": None, + "last_invalidated_event_name": None, + "last_invalidated_event_at": None, + } + ) + return { + **cube, + "snapshot_payload_json": dict(cube.get("snapshot_payload_json") or semantic_payload.get("snapshot_payload") or ZERO_LIBRARY_STATS), + "source_breakdown_json": dict(cube.get("source_breakdown_json") or semantic_payload.get("source_breakdown") or {}), + } + + def get_stats(self, *, account_id: Optional[str]) -> Dict[str, Any]: + if not account_id: + return dict(ZERO_LIBRARY_STATS) + current_source_updated_at = self.source_updated_at(account_id=account_id) + cube = self.repository.get_library_stats_cube(account_id, default={}) + if cube and not self.is_stale(cube, source_updated_at=current_source_updated_at): + return dict(cube.get("snapshot_payload_json") or ZERO_LIBRARY_STATS) + synced = self.sync_account(account_id=account_id, source_updated_at=current_source_updated_at) + return dict(synced.get("snapshot_payload_json") or ZERO_LIBRARY_STATS) diff --git a/src/narrativeos/services/library_stats_cube_projection.py b/src/narrativeos/services/library_stats_cube_projection.py new file mode 100644 index 0000000..024ef67 --- /dev/null +++ b/src/narrativeos/services/library_stats_cube_projection.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + +from .library_stats_cube import LibraryStatsCubeService + + +LIBRARY_STATS_INVALIDATION_EVENTS = { + "session_created", + "continue_story", + "author_work_created", + "author_work_branch_created", + "author_work_deleted", + "session_deleted", +} + + +class LibraryStatsCubeProjectionService: + def __init__(self, *, cube_service: LibraryStatsCubeService) -> None: + self.cube = cube_service + + def _account_id_from_event(self, event: Dict[str, Any]) -> Optional[str]: + payload_json = dict(event.get("payload_json") or {}) + account_id = str(payload_json.get("account_id") or event.get("reader_id") or "").strip() + return account_id or None + + def on_analytics_event(self, event: Dict[str, Any]) -> None: + event_name = str(event.get("event_name") or "").strip() + if event_name not in LIBRARY_STATS_INVALIDATION_EVENTS: + return + account_id = self._account_id_from_event(event) + if not account_id: + return + self.cube.invalidate_account( + account_id=account_id, + event_name=event_name, + occurred_at=str(event.get("occurred_at") or "").strip() or None, + ) diff --git a/src/narrativeos/services/library_stats_semantic_layer.py b/src/narrativeos/services/library_stats_semantic_layer.py new file mode 100644 index 0000000..f380670 --- /dev/null +++ b/src/narrativeos/services/library_stats_semantic_layer.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from sqlalchemy import desc, select + +from ..persistence.db import AnalyticsEventRow, AuthorWorkRow, SessionRow +from ..persistence.repositories import SQLAlchemyPlatformRepository + + +LIBRARY_STATS_SEMANTIC_VERSION = "library_stats_semantic/v2" + + +def _parse_timestamp(value: Optional[str]) -> datetime: + normalized = str(value or "").strip() + if not normalized: + return datetime.fromtimestamp(0, tz=timezone.utc) + try: + parsed = datetime.fromisoformat(normalized.replace("Z", "+00:00")) + except ValueError: + return datetime.fromtimestamp(0, tz=timezone.utc) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +class LibraryStatsSemanticLayerService: + def __init__(self, repository: SQLAlchemyPlatformRepository) -> None: + self.repository = repository + + def _session_rows(self, *, account_id: str) -> List[SessionRow]: + with self.repository.SessionLocal() as session: + stmt = ( + select(SessionRow) + .where(SessionRow.reader_id == account_id) + .order_by(desc(SessionRow.updated_at)) + ) + return list(session.execute(stmt).scalars().all()) + + def _author_work_rows(self, *, account_id: str) -> List[AuthorWorkRow]: + with self.repository.SessionLocal() as session: + stmt = ( + select(AuthorWorkRow) + .where(AuthorWorkRow.account_id == account_id) + .order_by(desc(AuthorWorkRow.updated_at)) + ) + return list(session.execute(stmt).scalars().all()) + + def _continue_counts_by_session(self, *, account_id: str) -> Dict[str, int]: + counts: Dict[str, int] = {} + for item in self.repository.list_analytics_events( + event_names=["continue_story"], + reader_id=account_id, + limit=2000, + ): + session_id = str(item.get("session_id") or "").strip() + if not session_id: + continue + counts[session_id] = counts.get(session_id, 0) + 1 + return counts + + def source_updated_at_inputs(self, *, account_id: str) -> Dict[str, Optional[str]]: + latest_session_updated_at = None + latest_continue_story_at = None + latest_author_work_updated_at = None + with self.repository.SessionLocal() as session: + latest_session_updated_at = session.execute( + select(SessionRow.updated_at) + .where(SessionRow.reader_id == account_id) + .order_by(desc(SessionRow.updated_at)) + .limit(1) + ).scalar_one_or_none() + latest_continue_story_at = session.execute( + select(AnalyticsEventRow.occurred_at) + .where( + AnalyticsEventRow.reader_id == account_id, + AnalyticsEventRow.event_name.in_(["session_created", "continue_story"]), + ) + .order_by(desc(AnalyticsEventRow.occurred_at)) + .limit(1) + ).scalar_one_or_none() + latest_author_work_updated_at = session.execute( + select(AuthorWorkRow.updated_at) + .where(AuthorWorkRow.account_id == account_id) + .order_by(desc(AuthorWorkRow.updated_at)) + .limit(1) + ).scalar_one_or_none() + return { + "latest_session_updated_at": str(latest_session_updated_at or "") or None, + "latest_continue_story_at": str(latest_continue_story_at or "") or None, + "latest_author_work_updated_at": str(latest_author_work_updated_at or "") or None, + } + + def source_updated_at(self, *, account_id: str) -> str: + latest = datetime.fromtimestamp(0, tz=timezone.utc) + for value in self.source_updated_at_inputs(account_id=account_id).values(): + latest = max(latest, _parse_timestamp(value)) + return latest.isoformat() + + def build_snapshot_payload(self, *, account_id: str) -> Dict[str, Any]: + session_rows = self._session_rows(account_id=account_id) + author_work_rows = self._author_work_rows(account_id=account_id) + continue_counts = self._continue_counts_by_session(account_id=account_id) + + reader_session_count = len(session_rows) + reader_continue_count = sum(continue_counts.values()) + reader_turn_units = sum( + max(int(getattr(row, "chapter_index", 0) or 0), continue_counts.get(str(getattr(row, "session_id", "") or ""), 0) + 1) + for row in session_rows + ) + author_work_count = len(author_work_rows) + author_parallel_branch_count = sum( + 1 for row in author_work_rows if str(getattr(row, "branch_kind", "") or "") == "parallel_universe" + ) + + world_ids = { + str(self.repository.get_world_version(str(getattr(row, "world_version_id", "") or "")).world_id or "").strip() + for row in session_rows + author_work_rows + if str(getattr(row, "world_version_id", "") or "").strip() + } + world_ids.discard("") + fragment_count = len( + {str(getattr(row, "session_id", "") or "").strip() for row in session_rows if str(getattr(row, "session_id", "") or "").strip()} + | {str(getattr(row, "work_id", "") or "").strip() for row in author_work_rows if str(getattr(row, "work_id", "") or "").strip()} + ) + + snapshot_payload = { + "totalPlayTime": round((reader_turn_units * 12) / 60.0, 1) if reader_turn_units else 0, + "totalBranches": reader_continue_count + author_parallel_branch_count, + "worldFragments": len(world_ids), + "totalFragments": fragment_count, + } + source_breakdown = { + "reader_session_count": reader_session_count, + "reader_continue_count": reader_continue_count, + "reader_turn_units": reader_turn_units, + "author_work_count": author_work_count, + "author_parallel_branch_count": author_parallel_branch_count, + "world_count": len(world_ids), + "fragment_count": fragment_count, + "source_updated_at_inputs": self.source_updated_at_inputs(account_id=account_id), + } + return { + "semantic_version": LIBRARY_STATS_SEMANTIC_VERSION, + "snapshot_payload": snapshot_payload, + "source_breakdown": source_breakdown, + "source_updated_at": self.source_updated_at(account_id=account_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/monetization.py b/src/narrativeos/services/monetization.py index 9e6e3a5..e94545b 100644 --- a/src/narrativeos/services/monetization.py +++ b/src/narrativeos/services/monetization.py @@ -1,10 +1,15 @@ from __future__ import annotations +from decimal import Decimal import json +import os +import base64 from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Dict, List, Optional +import httpx + from ..persistence.repositories import SQLAlchemyPlatformRepository @@ -20,6 +25,40 @@ def start_checkout(self, *, account_id: str, tier_id: str) -> Dict[str, Any]: "status": "created", } + def start_one_time_checkout( + self, + *, + account_id: str, + package_id: str, + amount: float, + bonus: float, + price_usd: float, + ) -> Dict[str, Any]: + return { + "provider": self.provider_id, + "package_id": package_id, + "checkout_url": f"https://stub.local/checkout/story-credits/{package_id}?account_id={account_id}&amount={int(amount)}&bonus={int(bonus)}&price={price_usd}", + "session_id": f"checkout_{account_id}_{package_id}", + "status": "created", + } + + def start_one_time_checkout( + self, + *, + account_id: str, + package_id: str, + amount: float, + bonus: float, + price_usd: float, + ) -> Dict[str, Any]: + return { + "provider": self.provider_id, + "package_id": package_id, + "checkout_url": f"https://stub.local/checkout/story-credits/{package_id}?account_id={account_id}&amount={int(amount)}&bonus={int(bonus)}&price={price_usd}", + "session_id": f"checkout_{account_id}_{package_id}", + "status": "created", + } + class AppStoreProviderStub: provider_id = "app_store_stub" @@ -29,14 +68,482 @@ class GooglePlayProviderStub: provider_id = "google_play_stub" +def _decode_jwt_payload(value: str) -> Dict[str, Any]: + parts = str(value or "").split(".") + if len(parts) < 2: + return {} + payload = parts[1] + padding = "=" * ((4 - len(payload) % 4) % 4) + try: + decoded = base64.urlsafe_b64decode((payload + padding).encode("utf-8")).decode("utf-8") + parsed = json.loads(decoded) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + + +class AppStoreProvider: + provider_id = "app_store" + + def __init__(self) -> None: + self.bearer_token = str(os.getenv("NARRATIVEOS_APPLE_BILLING_BEARER_TOKEN", "")).strip() or None + self.default_environment = str(os.getenv("NARRATIVEOS_APPLE_BILLING_ENV", "sandbox")).strip() or "sandbox" + self.tier_map = self._tier_map(os.getenv("NARRATIVEOS_APPLE_TIER_MAP_JSON", "")) + + def _tier_map(self, raw: str) -> Dict[str, str]: + if not raw.strip(): + return {} + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return {} + if not isinstance(payload, dict): + return {} + return {str(key): str(value) for key, value in payload.items() if str(key) and str(value)} + + def configured(self) -> bool: + return bool(self.bearer_token) + + def _environment(self, value: Optional[str] = None) -> str: + environment = str(value or self.default_environment or "sandbox").lower() + return "production" if environment in {"production", "prod", "live"} else "sandbox" + + def _base_url(self, environment: str) -> str: + if environment == "production": + return "https://api.storekit.itunes.apple.com" + return "https://api.storekit-sandbox.itunes.apple.com" + + def verify_purchase( + self, + *, + original_transaction_id: Optional[str] = None, + signed_transaction_info: Optional[str] = None, + tier_id: Optional[str] = None, + environment: Optional[str] = None, + ) -> Dict[str, Any]: + env_name = self._environment(environment) + decoded = _decode_jwt_payload(signed_transaction_info or "") + if original_transaction_id and self.configured(): + response = httpx.get( + f"{self._base_url(env_name)}/inApps/v1/subscriptions/{original_transaction_id}", + headers={"Authorization": f"Bearer {self.bearer_token}"}, + timeout=20.0, + ) + response.raise_for_status() + payload = response.json() + last_transactions = list(payload.get("data") or []) + transaction_info = decoded + if last_transactions: + last_transactions = list((last_transactions[0] or {}).get("lastTransactions") or []) + if last_transactions: + transaction_info = _decode_jwt_payload(last_transactions[0].get("signedTransactionInfo", "")) + product_id = str(transaction_info.get("productId") or "").strip() + resolved_tier = self.tier_map.get(product_id) or tier_id or product_id or "play_pass" + return { + "provider": self.provider_id, + "provider_ref": str(transaction_info.get("originalTransactionId") or original_transaction_id or ""), + "provider_order_id": str(transaction_info.get("transactionId") or ""), + "provider_customer_id": None, + "provider_checkout_session_id": None, + "tier_id": resolved_tier, + "status": "active" if str(transaction_info.get("expiresDate") or "") else "trialing", + "period_start": None, + "period_end": None, + "cancel_at_period_end": False, + "environment": env_name, + "verification_status": "verified", + "payload_json": {"apple_response": payload, "transaction_info": transaction_info}, + } + if decoded: + product_id = str(decoded.get("productId") or "").strip() + expires_ms = decoded.get("expiresDate") + purchase_ms = decoded.get("purchaseDate") + return { + "provider": self.provider_id, + "provider_ref": str(decoded.get("originalTransactionId") or original_transaction_id or ""), + "provider_order_id": str(decoded.get("transactionId") or ""), + "provider_customer_id": None, + "provider_checkout_session_id": None, + "tier_id": self.tier_map.get(product_id) or tier_id or product_id or "play_pass", + "status": "active" if expires_ms else "trialing", + "period_start": datetime.fromtimestamp(int(purchase_ms) / 1000.0, tz=timezone.utc).isoformat() if purchase_ms else None, + "period_end": datetime.fromtimestamp(int(expires_ms) / 1000.0, tz=timezone.utc).isoformat() if expires_ms else None, + "cancel_at_period_end": False, + "environment": str(decoded.get("environment") or env_name), + "verification_status": "decoded_unverified", + "payload_json": {"transaction_info": decoded}, + } + raise RuntimeError("app_store_not_configured") + + def parse_server_notification(self, payload: Dict[str, Any]) -> Dict[str, Any]: + signed_payload = str(payload.get("signedPayload") or "") + decoded = _decode_jwt_payload(signed_payload) + data = dict(decoded.get("data") or {}) + signed_transaction_info = data.get("signedTransactionInfo") + verified = self.verify_purchase( + original_transaction_id=(data.get("originalTransactionId") or payload.get("originalTransactionId")), + signed_transaction_info=signed_transaction_info, + environment=decoded.get("environment"), + ) + verified["payload_json"] = { + **dict(verified.get("payload_json") or {}), + "notification": payload, + "signed_payload_decoded": decoded, + } + verified["provider_event_type"] = str(decoded.get("notificationType") or payload.get("notificationType") or "apple_notification") + return verified + + +class GooglePlayProvider: + provider_id = "google_play" + + def __init__(self) -> None: + self.access_token = str(os.getenv("NARRATIVEOS_GOOGLE_PLAY_ACCESS_TOKEN", "")).strip() or None + self.package_name = str(os.getenv("NARRATIVEOS_GOOGLE_PLAY_PACKAGE_NAME", "")).strip() or None + self.default_environment = str(os.getenv("NARRATIVEOS_GOOGLE_PLAY_ENV", "production")).strip() or "production" + self.tier_map = self._tier_map(os.getenv("NARRATIVEOS_GOOGLE_PLAY_TIER_MAP_JSON", "")) + + def _tier_map(self, raw: str) -> Dict[str, str]: + if not raw.strip(): + return {} + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return {} + if not isinstance(payload, dict): + return {} + return {str(key): str(value) for key, value in payload.items() if str(key) and str(value)} + + def configured(self) -> bool: + return bool(self.access_token and self.package_name) + + def verify_purchase( + self, + *, + purchase_token: str, + subscription_id: Optional[str] = None, + package_name: Optional[str] = None, + tier_id: Optional[str] = None, + environment: Optional[str] = None, + ) -> Dict[str, Any]: + resolved_package_name = str(package_name or self.package_name or "").strip() + if not resolved_package_name or not purchase_token or not self.configured(): + raise RuntimeError("google_play_not_configured") + resolved_subscription_id = str(subscription_id or "").strip() + response = httpx.get( + f"https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{resolved_package_name}/purchases/subscriptionsv2/tokens/{purchase_token}", + headers={"Authorization": f"Bearer {self.access_token}"}, + timeout=20.0, + ) + response.raise_for_status() + payload = response.json() + line_items = list(payload.get("lineItems") or []) + latest_item = line_items[0] if line_items else {} + product_id = str(latest_item.get("productId") or resolved_subscription_id or "").strip() + expiry_time = latest_item.get("expiryTime") + start_time = latest_item.get("startTime") + return { + "provider": self.provider_id, + "provider_ref": purchase_token, + "provider_order_id": str(payload.get("latestOrderId") or ""), + "provider_customer_id": str(payload.get("obfuscatedExternalAccountId") or ""), + "provider_checkout_session_id": None, + "tier_id": self.tier_map.get(product_id) or tier_id or product_id or "play_pass", + "status": "active" if str(payload.get("subscriptionState") or "").upper() in {"SUBSCRIPTION_STATE_ACTIVE", "SUBSCRIPTION_STATE_IN_GRACE_PERIOD"} else "canceled", + "period_start": start_time, + "period_end": expiry_time, + "cancel_at_period_end": str(payload.get("subscriptionState") or "").upper() in {"SUBSCRIPTION_STATE_CANCELED", "SUBSCRIPTION_STATE_EXPIRED"}, + "environment": str(environment or self.default_environment or "production"), + "verification_status": "verified", + "payload_json": payload, + } + + def parse_notification(self, payload: Dict[str, Any]) -> Dict[str, Any]: + message = dict(payload.get("message") or {}) + notification = dict(payload.get("subscriptionNotification") or message.get("subscriptionNotification") or payload.get("subscription_notification") or {}) + purchase_token = str(notification.get("purchaseToken") or payload.get("purchaseToken") or "") + subscription_id = str(notification.get("subscriptionId") or payload.get("subscriptionId") or "") + verified = self.verify_purchase( + purchase_token=purchase_token, + subscription_id=subscription_id, + package_name=payload.get("packageName") or message.get("packageName"), + ) + verified["payload_json"] = { + **dict(verified.get("payload_json") or {}), + "notification": payload, + } + verified["provider_event_type"] = str(notification.get("notificationType") or payload.get("notificationType") or "google_notification") + return verified + + +class StripeCheckoutProvider: + provider_id = "stripe" + api_version = "2026-02-25.clover" + + def __init__( + self, + *, + subscription_price_map: Dict[str, str], + ink_price_map: Dict[str, str], + app_base_url: str, + secret_key: Optional[str], + publishable_key: Optional[str], + ) -> None: + self.subscription_price_map = {str(key): str(value) for key, value in dict(subscription_price_map or {}).items() if str(key) and str(value)} + self.ink_price_map = {str(key): str(value) for key, value in dict(ink_price_map or {}).items() if str(key) and str(value)} + self.app_base_url = str(app_base_url or "http://127.0.0.1:8000/app").rstrip("/") + self.secret_key = str(secret_key or "").strip() or None + self.publishable_key = str(publishable_key or "").strip() or None + + def configured(self) -> bool: + return bool(self.secret_key and (self.subscription_price_map or self.ink_price_map)) + + def subscription_prices_configured(self) -> bool: + return bool(self.secret_key and self.subscription_price_map) + + def ink_prices_configured(self) -> bool: + return bool(self.secret_key and self.ink_price_map) + + def _stripe_module(self): + try: + import stripe # type: ignore + except ModuleNotFoundError as exc: + raise RuntimeError("stripe_sdk_missing") from exc + stripe.api_key = self.secret_key + if hasattr(stripe, "api_version"): + stripe.api_version = self.api_version + return stripe + + def _payload(self, value: Any) -> Dict[str, Any]: + def _normalize(item: Any) -> Any: + if isinstance(item, Decimal): + return float(item) + if isinstance(item, dict): + return {str(key): _normalize(val) for key, val in item.items()} + if isinstance(item, (list, tuple)): + return [_normalize(entry) for entry in item] + return item + + if hasattr(value, "to_dict"): + return _normalize(dict(value.to_dict())) + return _normalize(dict(value)) + + def start_checkout( + self, + *, + account_id: str, + tier_id: str, + customer_email: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + success_url: Optional[str] = None, + cancel_url: Optional[str] = None, + ) -> Dict[str, Any]: + if not self.subscription_prices_configured(): + raise RuntimeError("stripe_not_configured") + price_id = self.subscription_price_map.get(tier_id) + if not price_id: + raise ValueError("stripe_price_not_configured_for_tier:%s" % tier_id) + stripe = self._stripe_module() + session = stripe.checkout.Session.create( + mode="subscription", + line_items=[{"price": price_id, "quantity": 1}], + success_url=success_url or f"{self.app_base_url}?checkout=success&checkout_session_id={{CHECKOUT_SESSION_ID}}", + cancel_url=cancel_url or f"{self.app_base_url}?checkout=cancel", + client_reference_id=account_id, + customer_email=customer_email or None, + metadata={ + "account_id": account_id, + "tier_id": tier_id, + **{str(key): str(value) for key, value in dict(metadata or {}).items()}, + }, + subscription_data={ + "metadata": { + "account_id": account_id, + "tier_id": tier_id, + } + }, + ) + payload = self._payload(session) + return { + "provider": self.provider_id, + "tier_id": tier_id, + "checkout_url": payload.get("url"), + "session_id": payload.get("id"), + "status": payload.get("status", "created"), + "provider_ref": payload.get("id"), + "price_id": price_id, + } + + def start_one_time_checkout( + self, + *, + account_id: str, + package_id: str, + customer_email: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + success_url: Optional[str] = None, + cancel_url: Optional[str] = None, + ) -> Dict[str, Any]: + if not self.ink_prices_configured(): + raise RuntimeError("stripe_not_configured") + price_id = self.ink_price_map.get(package_id) + if not price_id: + raise ValueError("stripe_price_not_configured_for_package:%s" % package_id) + stripe = self._stripe_module() + session = stripe.checkout.Session.create( + mode="payment", + line_items=[{"price": price_id, "quantity": 1}], + success_url=success_url or f"{self.app_base_url}?checkout=success&checkout_session_id={{CHECKOUT_SESSION_ID}}", + cancel_url=cancel_url or f"{self.app_base_url}?checkout=cancel", + client_reference_id=account_id, + customer_email=customer_email or None, + metadata={ + "account_id": account_id, + "package_id": package_id, + "checkout_kind": "ink", + "wallet_type": "story_credits", + **{str(key): str(value) for key, value in dict(metadata or {}).items()}, + }, + ) + payload = self._payload(session) + return { + "provider": self.provider_id, + "package_id": package_id, + "checkout_url": payload.get("url"), + "session_id": payload.get("id"), + "status": payload.get("status", "created"), + "provider_ref": payload.get("id"), + "price_id": price_id, + } + + def retrieve_checkout_session(self, *, session_id: str) -> Dict[str, Any]: + if not self.configured(): + raise RuntimeError("stripe_not_configured") + stripe = self._stripe_module() + session = stripe.checkout.Session.retrieve(session_id) + return self._payload(session) + + def retrieve_subscription(self, *, subscription_id: str) -> Dict[str, Any]: + if not self.configured(): + raise RuntimeError("stripe_not_configured") + stripe = self._stripe_module() + subscription = stripe.Subscription.retrieve(subscription_id) + return self._payload(subscription) + + def start_customer_portal( + self, + *, + customer_id: str, + return_url: Optional[str] = None, + ) -> Dict[str, Any]: + if not self.configured(): + raise RuntimeError("stripe_not_configured") + stripe = self._stripe_module() + session = stripe.billing_portal.Session.create( + customer=customer_id, + return_url=return_url or self.app_base_url, + ) + payload = self._payload(session) + return { + "provider": self.provider_id, + "customer_id": customer_id, + "portal_url": payload.get("url"), + "session_id": payload.get("id"), + } + + def ensure_customer( + self, + *, + customer_email: Optional[str], + metadata: Optional[Dict[str, Any]] = None, + customer_id: Optional[str] = None, + ) -> Dict[str, Any]: + if not self.configured(): + raise RuntimeError("stripe_not_configured") + stripe = self._stripe_module() + if customer_id: + customer = stripe.Customer.retrieve(customer_id) + else: + customer = stripe.Customer.create( + email=customer_email or None, + metadata={str(key): str(value) for key, value in dict(metadata or {}).items()}, + ) + return self._payload(customer) + + def create_invoice( + self, + *, + customer_id: str, + currency: str, + line_items: List[Dict[str, Any]], + metadata: Optional[Dict[str, Any]] = None, + days_until_due: int = 30, + ) -> Dict[str, Any]: + if not self.configured(): + raise RuntimeError("stripe_not_configured") + stripe = self._stripe_module() + for item in line_items: + amount_cents = int(round(float(item.get("amount_usd") or 0.0) * 100)) + stripe.InvoiceItem.create( + customer=customer_id, + currency=currency.lower(), + amount=amount_cents, + description=str(item.get("description") or item.get("metric_type") or "usage_charge"), + metadata={str(key): str(value) for key, value in dict(metadata or {}).items()}, + ) + invoice = stripe.Invoice.create( + customer=customer_id, + collection_method="send_invoice", + days_until_due=days_until_due, + # Stripe defaults pending_invoice_items_behavior to "exclude". + # We stage invoice items before invoice creation, so opt in to + # including those pending items on the generated invoice draft. + pending_invoice_items_behavior="include", + metadata={str(key): str(value) for key, value in dict(metadata or {}).items()}, + ) + finalized = stripe.Invoice.finalize_invoice(invoice["id"] if isinstance(invoice, dict) else getattr(invoice, "id")) + return self._payload(finalized) + + def retrieve_invoice(self, *, invoice_id: str) -> Dict[str, Any]: + if not self.configured(): + raise RuntimeError("stripe_not_configured") + stripe = self._stripe_module() + invoice = stripe.Invoice.retrieve(invoice_id) + return self._payload(invoice) + + def create_credit_note( + self, + *, + invoice_id: str, + amount_usd: float, + reason: Optional[str] = None, + ) -> Dict[str, Any]: + if not self.configured(): + raise RuntimeError("stripe_not_configured") + stripe = self._stripe_module() + credit_note = stripe.CreditNote.create( + invoice=invoice_id, + amount=int(round(float(amount_usd or 0.0) * 100)), + reason=reason or None, + ) + return self._payload(credit_note) + + class MonetizationService: def __init__(self, repository: SQLAlchemyPlatformRepository, *, base_dir: Optional[Path] = None) -> None: self.repository = repository self.base_dir = Path(base_dir or Path(__file__).resolve().parents[3]) self.tier_config = self._load_tier_config() self.web_checkout = WebCheckoutProvider() - self.app_store = AppStoreProviderStub() - self.google_play = GooglePlayProviderStub() + self.stripe_checkout = StripeCheckoutProvider( + subscription_price_map=self._stripe_price_map(), + ink_price_map=self._stripe_ink_price_map(), + app_base_url=self._app_base_url(), + secret_key=os.getenv("NARRATIVEOS_STRIPE_SECRET_KEY"), + publishable_key=os.getenv("NARRATIVEOS_STRIPE_PUBLISHABLE_KEY"), + ) + self.app_store = AppStoreProvider() + self.google_play = GooglePlayProvider() def _utcnow(self) -> datetime: return datetime.now(timezone.utc) @@ -44,8 +551,13 @@ def _utcnow(self) -> datetime: def _parse_datetime(self, value: Optional[str]) -> Optional[datetime]: if not value: return None + if isinstance(value, datetime): + parsed = value + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) try: - normalized = value.replace("Z", "+00:00") + normalized = str(value).replace("Z", "+00:00") parsed = datetime.fromisoformat(normalized) if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) @@ -57,6 +569,33 @@ def _load_tier_config(self) -> Dict[str, Any]: path = self.base_dir / "configs" / "monetization_tiers.json" return json.loads(path.read_text(encoding="utf-8")) + def _app_base_url(self) -> str: + return str(os.getenv("NARRATIVEOS_APP_BASE_URL", "http://127.0.0.1:8000/app")) + + def _stripe_price_map(self) -> Dict[str, str]: + raw = os.getenv("NARRATIVEOS_STRIPE_PRICE_MAP_JSON", "") + if not raw.strip(): + return {} + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return {} + if not isinstance(payload, dict): + return {} + return {str(key): str(value) for key, value in payload.items() if str(key) and str(value)} + + def _stripe_ink_price_map(self) -> Dict[str, str]: + raw = os.getenv("NARRATIVEOS_STRIPE_INK_PRICE_MAP_JSON", "") + if not raw.strip(): + return {} + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return {} + if not isinstance(payload, dict): + return {} + return {str(key): str(value) for key, value in payload.items() if str(key) and str(value)} + def config_version(self) -> str: return str(self.tier_config.get("config_version", "unknown")) @@ -75,6 +614,20 @@ def metering_rules(self) -> Dict[str, Any]: def credit_policy(self) -> Dict[str, Any]: return dict(self.tier_config.get("credit_policy", {})) + def ink_packages(self) -> List[Dict[str, Any]]: + return [dict(item) for item in list(self.tier_config.get("ink_packages") or [])] + + def get_ink_package(self, package_id: str) -> Dict[str, Any]: + normalized = str(package_id or "").strip() + for package in self.ink_packages(): + if normalized in { + str(package.get("package_id") or ""), + str(package.get("amount") or ""), + str(int(float(package.get("amount") or 0.0) + float(package.get("bonus") or 0.0))), + }: + return dict(package) + raise KeyError("unknown_ink_package:%s" % normalized) + def author_access_levels(self) -> Dict[str, int]: return { key: int(value) @@ -100,9 +653,45 @@ def config_snapshot(self) -> Dict[str, Any]: "config_version": self.config_version(), "tiers": self.tiers(), "credit_policy": self.credit_policy(), + "ink_packages": self.ink_packages(), "metering": self.metering_rules(), "entitlement_matrix": self.entitlement_matrix(), "author_access_levels": self.author_access_levels(), + "checkout_provider_status": self.checkout_provider_status(), + } + + def checkout_provider_status(self, provider: Optional[str] = None) -> Dict[str, Any]: + resolved_provider = str(provider or os.getenv("NARRATIVEOS_BILLING_PROVIDER", "web_stub")) + if resolved_provider == self.web_checkout.provider_id: + return { + "provider": resolved_provider, + "configured": True, + "publishable_key": None, + } + if resolved_provider == self.stripe_checkout.provider_id: + return { + "provider": resolved_provider, + "configured": self.stripe_checkout.configured(), + "publishable_key": self.stripe_checkout.publishable_key, + "subscription_prices_configured": self.stripe_checkout.subscription_prices_configured(), + "ink_prices_configured": self.stripe_checkout.ink_prices_configured(), + } + if resolved_provider == self.app_store.provider_id: + return { + "provider": resolved_provider, + "configured": self.app_store.configured(), + "publishable_key": None, + } + if resolved_provider == self.google_play.provider_id: + return { + "provider": resolved_provider, + "configured": self.google_play.configured(), + "publishable_key": None, + } + return { + "provider": resolved_provider, + "configured": False, + "publishable_key": None, } def resolve_account_id( @@ -312,7 +901,164 @@ def refill_subscription_wallets(self, subscription_id: str) -> Dict[str, Any]: "studio_wallet": studio_wallet, } - def start_checkout(self, *, account_id: str, tier_id: str, provider: str = "web_stub") -> Dict[str, Any]: - if provider != self.web_checkout.provider_id: - raise ValueError("unsupported_checkout_provider") - return self.web_checkout.start_checkout(account_id=account_id, tier_id=tier_id) + def start_checkout( + self, + *, + account_id: str, + tier_id: str, + provider: str = "web_stub", + customer_email: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + success_url: Optional[str] = None, + cancel_url: Optional[str] = None, + ) -> Dict[str, Any]: + if provider == self.web_checkout.provider_id: + return self.web_checkout.start_checkout(account_id=account_id, tier_id=tier_id) + if provider == self.stripe_checkout.provider_id: + return self.stripe_checkout.start_checkout( + account_id=account_id, + tier_id=tier_id, + customer_email=customer_email, + metadata=metadata, + success_url=success_url, + cancel_url=cancel_url, + ) + raise ValueError("unsupported_checkout_provider") + + def start_ink_checkout( + self, + *, + account_id: str, + package_id: str, + provider: str = "web_stub", + customer_email: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + success_url: Optional[str] = None, + cancel_url: Optional[str] = None, + ) -> Dict[str, Any]: + package = self.get_ink_package(package_id) + resolved_package_id = str(package.get("package_id") or package_id) + if provider == self.web_checkout.provider_id: + return self.web_checkout.start_one_time_checkout( + account_id=account_id, + package_id=resolved_package_id, + amount=float(package.get("amount") or 0.0), + bonus=float(package.get("bonus") or 0.0), + price_usd=float(package.get("price_usd") or 0.0), + ) + if provider == self.stripe_checkout.provider_id: + return self.stripe_checkout.start_one_time_checkout( + account_id=account_id, + package_id=resolved_package_id, + customer_email=customer_email, + metadata=metadata, + success_url=success_url, + cancel_url=cancel_url, + ) + raise ValueError("unsupported_checkout_provider") + + def start_customer_portal( + self, + *, + account_id: str, + customer_id: str, + provider: Optional[str] = None, + return_url: Optional[str] = None, + ) -> Dict[str, Any]: + resolved_provider = str(provider or os.getenv("NARRATIVEOS_BILLING_PROVIDER", "web_stub")) + if resolved_provider == self.web_checkout.provider_id: + return { + "provider": resolved_provider, + "customer_id": customer_id, + "portal_url": f"https://stub.local/customer-portal/{account_id}", + "session_id": f"portal_{account_id}", + } + if resolved_provider == self.stripe_checkout.provider_id: + return self.stripe_checkout.start_customer_portal( + customer_id=customer_id, + return_url=return_url, + ) + raise ValueError("unsupported_checkout_provider") + + def ensure_stripe_customer( + self, + *, + customer_email: Optional[str], + metadata: Optional[Dict[str, Any]] = None, + customer_id: Optional[str] = None, + ) -> Dict[str, Any]: + return self.stripe_checkout.ensure_customer( + customer_email=customer_email, + metadata=metadata, + customer_id=customer_id, + ) + + def issue_stripe_invoice( + self, + *, + customer_id: str, + currency: str, + line_items: List[Dict[str, Any]], + metadata: Optional[Dict[str, Any]] = None, + days_until_due: int = 30, + ) -> Dict[str, Any]: + return self.stripe_checkout.create_invoice( + customer_id=customer_id, + currency=currency, + line_items=line_items, + metadata=metadata, + days_until_due=days_until_due, + ) + + def retrieve_stripe_invoice(self, *, invoice_id: str) -> Dict[str, Any]: + return self.stripe_checkout.retrieve_invoice(invoice_id=invoice_id) + + def create_stripe_credit_note(self, *, invoice_id: str, amount_usd: float, reason: Optional[str] = None) -> Dict[str, Any]: + return self.stripe_checkout.create_credit_note(invoice_id=invoice_id, amount_usd=amount_usd, reason=reason) + + def retrieve_checkout_session( + self, + *, + checkout_session_id: str, + provider: Optional[str] = None, + ) -> Dict[str, Any]: + resolved_provider = str(provider or os.getenv("NARRATIVEOS_BILLING_PROVIDER", "web_stub")) + if resolved_provider == self.stripe_checkout.provider_id: + return self.stripe_checkout.retrieve_checkout_session(session_id=checkout_session_id) + raise ValueError("unsupported_checkout_provider") + + def retrieve_subscription( + self, + *, + subscription_ref: str, + provider: Optional[str] = None, + ) -> Dict[str, Any]: + resolved_provider = str(provider or os.getenv("NARRATIVEOS_BILLING_PROVIDER", "web_stub")) + if resolved_provider == self.stripe_checkout.provider_id: + return self.stripe_checkout.retrieve_subscription(subscription_id=subscription_ref) + raise ValueError("unsupported_checkout_provider") + + def verify_mobile_purchase(self, *, provider: str, payload: Dict[str, Any]) -> Dict[str, Any]: + if provider == self.app_store.provider_id: + return self.app_store.verify_purchase( + original_transaction_id=payload.get("original_transaction_id"), + signed_transaction_info=payload.get("signed_transaction_info"), + tier_id=payload.get("tier_id"), + environment=payload.get("environment"), + ) + if provider == self.google_play.provider_id: + return self.google_play.verify_purchase( + purchase_token=payload["purchase_token"], + subscription_id=payload.get("subscription_id"), + package_name=payload.get("package_name"), + tier_id=payload.get("tier_id"), + environment=payload.get("environment"), + ) + raise ValueError("unsupported_mobile_purchase_provider") + + def ingest_store_notification(self, *, provider: str, payload: Dict[str, Any]) -> Dict[str, Any]: + if provider == self.app_store.provider_id: + return self.app_store.parse_server_notification(payload) + if provider == self.google_play.provider_id: + return self.google_play.parse_notification(payload) + raise ValueError("unsupported_store_notification_provider") diff --git a/src/narrativeos/services/observability.py b/src/narrativeos/services/observability.py index 1a3b1a4..8199c88 100644 --- a/src/narrativeos/services/observability.py +++ b/src/narrativeos/services/observability.py @@ -105,6 +105,8 @@ def build_runtime_receipt( reader_view: Optional[Dict[str, Any]] = None, estimated_cost: float = 0.0, runtime_latency_ms: Optional[float] = None, + trace_id: Optional[str] = None, + quality_event_id: Optional[str] = None, ) -> Dict[str, Any]: candidate_debug = dict((candidate_batch or {}).get("debug") or {}) candidate_routing = self._routing_payload(candidate_debug.get("backend_routing")) @@ -151,7 +153,13 @@ def build_runtime_receipt( backend_error = candidate_backend_error or renderer_backend_error candidate_attempt_count = int(self._deep_find(candidate_routing, "attempt_count") or 0) - renderer_attempt_count = int(self._deep_find(render_routing, "attempt_count") or 0) + renderer_attempt_count = int( + render_debug.get("renderer_attempt_count") + or self._deep_find(render_routing, "renderer_attempt_count") + or self._deep_find(render_routing, "attempt_count") + or 0 + ) + renderer_length_retry_count = max(0, renderer_attempt_count - 1) candidate_latency_ms = self._safe_float(self._deep_find(candidate_routing, "latency_ms")) renderer_latency_ms = self._safe_float(self._deep_find(render_routing, "latency_ms")) runtime_latency = self._safe_float(runtime_latency_ms) @@ -184,6 +192,8 @@ def build_runtime_receipt( "session_id": session_id, "account_id": account_id, "reader_id": reader_id, + "trace_id": trace_id, + "quality_event_id": quality_event_id, "provider": candidate_debug.get("provider") or render_debug.get("renderer"), "selected_provider": selected_provider, "candidate_selected_provider": candidate_selected_provider, @@ -209,10 +219,14 @@ def build_runtime_receipt( "attempt_count": candidate_attempt_count or renderer_attempt_count or 0, "candidate_attempt_count": candidate_attempt_count, "renderer_attempt_count": renderer_attempt_count, + "renderer_length_retry_count": renderer_length_retry_count, + "renderer_length_retry_used": renderer_length_retry_count > 0, "runtime_latency_ms": round(float(runtime_latency), 3) if runtime_latency is not None else None, "candidate_latency_ms": round(float(candidate_latency_ms), 3) if candidate_latency_ms is not None else None, "renderer_latency_ms": round(float(renderer_latency_ms), 3) if renderer_latency_ms is not None else None, "renderer_fallback_reason": render_debug.get("renderer_fallback_reason"), + "llm_payload_gate": dict(render_debug.get("llm_payload_gate") or {}), + "llm_length_gate": dict(render_debug.get("llm_length_gate") or {}), "estimated_cost": float(estimated_cost or 0.0), "candidate_estimated_request_cost_usd": candidate_budget_estimate.get("estimated_cost_usd"), "renderer_estimated_request_cost_usd": renderer_budget_estimate.get("estimated_cost_usd"), @@ -372,6 +386,8 @@ def provider_runtime_metrics( "fallback_count": 0, "budget_block_count": 0, "backend_error_count": 0, + "length_retry_count": 0, + "renderer_attempt_total": 0, "cache_hits": 0, "cache_observed": 0, "total_estimated_cost": 0.0, @@ -392,6 +408,8 @@ def provider_runtime_metrics( provider_bucket["fallback_count"] += 1 if item.get("fallback_used") else 0 provider_bucket["budget_block_count"] += 1 if item.get("budget_blocked") else 0 provider_bucket["backend_error_count"] += 1 if item.get("backend_error") else 0 + provider_bucket["length_retry_count"] += 1 if item.get("renderer_length_retry_used") else 0 + provider_bucket["renderer_attempt_total"] += int(item.get("renderer_attempt_count") or 0) if item.get("cache_hit") is not None: provider_bucket["cache_observed"] += 1 provider_bucket["cache_hits"] += 1 if item.get("cache_hit") else 0 @@ -430,6 +448,8 @@ def provider_runtime_metrics( "fallback_count": 0, "budget_block_count": 0, "backend_error_count": 0, + "length_retry_count": 0, + "renderer_attempt_total": 0, "canary_match_count": 0, "total_estimated_cost": 0.0, "runtime_latencies": [], @@ -441,6 +461,8 @@ def provider_runtime_metrics( stage_bucket["fallback_count"] += 1 if item.get("fallback_used") else 0 stage_bucket["budget_block_count"] += 1 if item.get("budget_blocked") else 0 stage_bucket["backend_error_count"] += 1 if item.get("backend_error") else 0 + stage_bucket["length_retry_count"] += 1 if item.get("renderer_length_retry_used") else 0 + stage_bucket["renderer_attempt_total"] += int(item.get("renderer_attempt_count") or 0) stage_bucket["canary_match_count"] += 1 if item.get(f"{track}_canary_match") else 0 stage_bucket["total_estimated_cost"] += cost if item.get("runtime_latency_ms") is not None: @@ -490,6 +512,8 @@ def provider_runtime_metrics( "fallback_rate": round(payload["fallback_count"] / float(receipt_count), 3) if receipt_count else 0.0, "budget_block_rate": round(payload["budget_block_count"] / float(receipt_count), 3) if receipt_count else 0.0, "backend_error_rate": round(payload["backend_error_count"] / float(receipt_count), 3) if receipt_count else 0.0, + "length_retry_rate": round(payload["length_retry_count"] / float(receipt_count), 3) if receipt_count else 0.0, + "avg_renderer_attempt_count": round(payload["renderer_attempt_total"] / float(receipt_count), 3) if receipt_count else 0.0, "cache_hit_rate": ( round(payload["cache_hits"] / float(payload["cache_observed"]), 3) if payload["cache_observed"] @@ -549,6 +573,8 @@ def provider_runtime_metrics( "fallback_rate": round(payload["fallback_count"] / float(receipt_count), 3) if receipt_count else 0.0, "budget_block_rate": round(payload["budget_block_count"] / float(receipt_count), 3) if receipt_count else 0.0, "backend_error_rate": round(payload["backend_error_count"] / float(receipt_count), 3) if receipt_count else 0.0, + "length_retry_rate": round(payload["length_retry_count"] / float(receipt_count), 3) if receipt_count else 0.0, + "avg_renderer_attempt_count": round(payload["renderer_attempt_total"] / float(receipt_count), 3) if receipt_count else 0.0, "canary_match_count": int(payload["canary_match_count"]), "total_estimated_cost": round(payload["total_estimated_cost"], 6), "avg_estimated_cost": round(payload["total_estimated_cost"] / float(receipt_count), 6) if receipt_count else 0.0, @@ -580,3 +606,125 @@ def provider_runtime_metrics( "renderer": self._latency_summary(renderer_latencies), }, } + + def story_bootstrap_world_summary(self, *, limit: int = 50) -> Dict[str, Any]: + events = self.repository.list_analytics_events( + event_names=["story_import_bootstrap_completed"], + limit=max(limit * 20, 500), + ) + world_summary: Dict[str, Dict[str, Any]] = {} + for event in events: + payload = dict(event.get("payload_json") or {}) + world_id = str(payload.get("world_id") or "").strip() + if not world_id: + continue + entry = world_summary.setdefault( + world_id, + { + "worldId": world_id, + "attemptedCount": 0, + "firstAttemptQualityGuardFailedCount": 0, + "retriedRecoveryCount": 0, + "finalQualityGuardFailedCount": 0, + }, + ) + entry["attemptedCount"] += 1 + if str(payload.get("first_attempt_result_status") or "") == "quality_guard_failed": + entry["firstAttemptQualityGuardFailedCount"] += 1 + if bool(payload.get("recovered_after_retry")): + entry["retriedRecoveryCount"] += 1 + if str(payload.get("result_status") or "") == "quality_guard_failed": + entry["finalQualityGuardFailedCount"] += 1 + + rows: List[Dict[str, Any]] = [] + for item in world_summary.values(): + attempted = int(item["attemptedCount"] or 0) + first_failed = int(item["firstAttemptQualityGuardFailedCount"] or 0) + retried_recovered = int(item["retriedRecoveryCount"] or 0) + final_failed = int(item["finalQualityGuardFailedCount"] or 0) + first_rate = round(first_failed / float(max(1, attempted)), 3) + final_rate = round(final_failed / float(max(1, attempted)), 3) + rows.append( + { + **item, + "firstAttemptQualityGuardFailedRate": first_rate, + "retriedRecoveryRate": round(retried_recovered / float(max(1, first_failed)), 3) if first_failed else 0.0, + "finalQualityGuardFailedRate": final_rate, + "qualityGuardCollisionDelta": round(first_rate - final_rate, 3), + } + ) + rows.sort( + key=lambda item: ( + -float(item.get("qualityGuardCollisionDelta", 0.0) or 0.0), + -int(item.get("attemptedCount", 0) or 0), + str(item.get("worldId") or ""), + ) + ) + return { + "generated_at": self._utcnow(), + "worlds": rows[:limit], + } + + def story_bootstrap_world_detail(self, world_id: str, *, limit: int = 20) -> Dict[str, Any]: + normalized_world_id = str(world_id or "").strip() + events = self.repository.list_analytics_events( + event_names=["story_import_bootstrap_completed"], + limit=max(limit * 50, 500), + ) + matching_events = [ + event + for event in events + if str(dict(event.get("payload_json") or {}).get("world_id") or "").strip() == normalized_world_id + ] + if not matching_events: + raise KeyError(f"unknown_story_bootstrap_world:{normalized_world_id}") + + rows: List[Dict[str, Any]] = [] + for event in matching_events[:limit]: + payload = dict(event.get("payload_json") or {}) + rows.append( + { + "sessionId": event.get("session_id"), + "worldId": normalized_world_id, + "worldVersionId": payload.get("world_version_id") or event.get("world_version_id"), + "attemptCount": int(payload.get("attempt_index") or 0), + "firstAttemptResultStatus": payload.get("first_attempt_result_status") or payload.get("result_status"), + "finalResultStatus": payload.get("result_status"), + "recoveredAfterRetry": bool(payload.get("recovered_after_retry")), + "bootstrapIntent": payload.get("bootstrap_intent"), + "occurredAt": event.get("occurred_at"), + } + ) + + attempted = len(matching_events) + first_failed = sum( + 1 + for event in matching_events + if str(dict(event.get("payload_json") or {}).get("first_attempt_result_status") or "") == "quality_guard_failed" + ) + final_failed = sum( + 1 + for event in matching_events + if str(dict(event.get("payload_json") or {}).get("result_status") or "") == "quality_guard_failed" + ) + retried_recovered = sum( + 1 for event in matching_events if bool(dict(event.get("payload_json") or {}).get("recovered_after_retry")) + ) + first_rate = round(first_failed / float(max(1, attempted)), 3) + final_rate = round(final_failed / float(max(1, attempted)), 3) + summary = { + "worldId": normalized_world_id, + "attemptedCount": attempted, + "firstAttemptQualityGuardFailedCount": first_failed, + "firstAttemptQualityGuardFailedRate": first_rate, + "retriedRecoveryCount": retried_recovered, + "retriedRecoveryRate": round(retried_recovered / float(max(1, first_failed)), 3) if first_failed else 0.0, + "finalQualityGuardFailedCount": final_failed, + "finalQualityGuardFailedRate": final_rate, + "qualityGuardCollisionDelta": round(first_rate - final_rate, 3), + } + return { + "generated_at": self._utcnow(), + "world": summary, + "rows": rows, + } diff --git a/src/narrativeos/services/ops_account_workspace.py b/src/narrativeos/services/ops_account_workspace.py index 39aee82..cc5f2dc 100644 --- a/src/narrativeos/services/ops_account_workspace.py +++ b/src/narrativeos/services/ops_account_workspace.py @@ -5,6 +5,7 @@ from ..persistence.repositories import SQLAlchemyPlatformRepository from .billing import BillingService +from .customer_accounts import CustomerAccountService from .governance import GovernanceService from .ops_alerting import OpsAlertingService from .ops_traceability import OpsTraceabilityService @@ -16,12 +17,14 @@ def __init__( repository: SQLAlchemyPlatformRepository, *, billing_service: BillingService, + customer_account_service: CustomerAccountService, governance_service: GovernanceService, ops_alerting_service: OpsAlertingService, ops_traceability_service: OpsTraceabilityService, ) -> None: self.repository = repository self.billing = billing_service + self.customer_accounts = customer_account_service self.governance = governance_service self.alerting = ops_alerting_service self.traceability = ops_traceability_service @@ -385,6 +388,11 @@ def _operator_timeline( def account_workspace(self, *, account_id: str, limit: int = 12) -> Dict[str, Any]: detail = self.billing.account_detail(account_id=account_id, limit=limit) + customer_detail = ( + self.customer_accounts.customer_account_detail(account_id=account_id) + if self.repository.get_customer_account_by_account_id(account_id, default=None) + else None + ) governance_snapshot = self.governance.account_snapshot(account_id=account_id, limit=limit) alerts_payload = self.alerting.list_alerts(account_id=account_id, status_filter="actionable", limit=8) investigation = self.traceability.investigate_account(account_id=account_id, limit=min(limit, 12)) @@ -422,13 +430,21 @@ def account_workspace(self, *, account_id: str, limit: int = 12) -> Dict[str, An "health_status": health_status, "subscription_status": subscription.get("status") or "inactive", "tier_id": subscription.get("tier_id"), + "effective_tier": detail.get("effective_tier"), + "provider_subscription_count": len(detail.get("provider_subscriptions") or []), "actionable_alert_count": int((alerts_payload.get("summary") or {}).get("actionable_alert_count") or 0), "support_issue_count": len(detail.get("support_issues") or []), "active_restriction_count": int((governance_snapshot.get("restriction_summary") or {}).get("active_restriction_count") or 0), "open_governance_case_count": int((governance_snapshot.get("governance_summary") or {}).get("open_case_count") or 0), "recommended_path": (investigation.get("recommended_paths") or [{}])[0].get("path_id"), "surface_statuses": surface_statuses, + "provider_source_summary": detail.get("provider_source_summary") or {}, + "email_verified": (detail.get("security_state") or {}).get("email_verified"), + "customer_account_status": (customer_detail or {}).get("lifecycle_summary", {}).get("status"), + "customer_plan_id": (customer_detail or {}).get("plan", {}).get("plan_id"), + "customer_renewal_risk": (customer_detail or {}).get("lifecycle_summary", {}).get("renewal_risk"), }, + "customer_lifecycle_summary": customer_detail, "wallet_posture": wallet_posture, "entitlement_posture": entitlement_posture, "top_blockers": top_blockers, @@ -445,6 +461,8 @@ def account_workspace(self, *, account_id: str, limit: int = 12) -> Dict[str, An "recent_session_ids": [item.get("session_id") for item in detail.get("recent_sessions", []) if item.get("session_id")], "recent_world_version_ids": [item.get("world_version_id") for item in detail.get("recent_drafts", []) if item.get("world_version_id")], "subscription_id": subscription.get("subscription_id"), + "provider_subscription_ids": [item.get("provider_subscription_id") for item in detail.get("provider_subscriptions", [])], + "customer_account_id": (customer_detail or {}).get("customer_account", {}).get("customer_account_id"), }, "operator_timeline": operator_timeline, } diff --git a/src/narrativeos/services/ops_alerting.py b/src/narrativeos/services/ops_alerting.py index 886e20b..4541efc 100644 --- a/src/narrativeos/services/ops_alerting.py +++ b/src/narrativeos/services/ops_alerting.py @@ -3,7 +3,7 @@ import json import os from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional from ..persistence.repositories import SQLAlchemyPlatformRepository from .async_jobs import AsyncJobService @@ -13,6 +13,9 @@ from .ops_traceability import OpsTraceabilityService from .runtime_ops import RuntimeOpsService +if TYPE_CHECKING: + from .commercial_audit import CommercialAuditService + class OpsAlertingService: VALID_STATUSES = {"open", "acknowledged", "resolved", "suppressed"} @@ -27,6 +30,7 @@ def __init__( runtime_ops_service: RuntimeOpsService, async_job_service: AsyncJobService, ops_traceability_service: OpsTraceabilityService, + audit_service: Optional["CommercialAuditService"] = None, ) -> None: self.repository = repository self.billing = billing_service @@ -35,6 +39,7 @@ def __init__( self.runtime_ops = runtime_ops_service self.async_jobs = async_job_service self.traceability = ops_traceability_service + self.audit = audit_service def _utcnow(self) -> str: return datetime.now(timezone.utc).isoformat() @@ -496,6 +501,11 @@ def _build_alert_detail(self, alert: Dict[str, Any]) -> Dict[str, Any]: "incident_snapshot": self.async_jobs.incident_snapshot(limit=20), "standard_actions": ["recover_incidents", "retry_failed_jobs", "acknowledge_alert"], } + detail["operator_audit_trail"] = self.repository.list_audit_logs( + object_type="ops_alert", + object_id=str(alert.get("alert_id") or ""), + limit=20, + ) return detail def alert_detail(self, alert_id: str, *, account_id: Optional[str] = None) -> Dict[str, Any]: @@ -513,13 +523,16 @@ def update_alert_status( *, status: str, reviewer_id: Optional[str] = None, + actor_role: Optional[str] = None, note: Optional[str] = None, account_id: Optional[str] = None, + source_surface: str = "ops_api", ) -> Dict[str, Any]: if status not in self.VALID_STATUSES: raise ValueError("invalid_alert_status") detail = self.alert_detail(alert_id, account_id=account_id) alert = dict(detail["alert"]) + previous_status = str(alert.get("status") or "open") self.repository.save_review_record( { "asset_type": "ops_alert", @@ -540,4 +553,34 @@ def update_alert_status( ), } ) + if self.audit is not None: + action_type = { + "acknowledged": "ops_alert_acknowledged", + "resolved": "ops_alert_resolved", + }.get(status, "ops_alert_status_changed") + self.audit.record_audit_log( + actor_id=str(reviewer_id or "ops_unknown"), + actor_role=str(actor_role or "reviewer"), + account_id=str(alert.get("account_id") or account_id or "") or None, + object_type="ops_alert", + object_id=alert_id, + action_type=action_type, + source_surface=source_surface, + customer_visible_payload={ + "status": status, + "summary": str(alert.get("summary") or alert.get("title") or "ops alert updated"), + "category": alert.get("category"), + }, + internal_payload={ + "alert_id": alert_id, + "account_id": alert.get("account_id") or account_id, + "category": alert.get("category"), + "source_type": alert.get("source_type"), + "previous_status": previous_status, + "next_status": status, + "note": note, + "recommended_actions": list(alert.get("recommended_actions") or []), + "investigation_ref": dict(alert.get("investigation_ref") or {}), + }, + ) return self.alert_detail(alert_id, account_id=account_id) diff --git a/src/narrativeos/services/ops_commercialization_dashboard.py b/src/narrativeos/services/ops_commercialization_dashboard.py new file mode 100644 index 0000000..fc9f5ee --- /dev/null +++ b/src/narrativeos/services/ops_commercialization_dashboard.py @@ -0,0 +1,507 @@ +from __future__ import annotations + +from collections import Counter +import os +from typing import Any, Dict, List, Optional + +from .commercial_support import CommercialSupportService +from .commercial_lifecycle_automation import CommercialLifecycleAutomationService +from .customer_accounts import CustomerAccountService +from .customer_success_reporting import CustomerSuccessReportingService +from .go_live_day_runner import GoLiveDayRunnerService +from .human_signoff_closure import HumanSignoffClosureService +from .launch_command_center import LaunchCommandCenterService +from .launch_week_guard import LaunchWeekGuardService +from .launch_week_monitoring import DEFAULT_PUBLIC_APP_URL, LaunchWeekMonitoringService +from .partner_readiness import PartnerReadinessService +from .production_acceptance import ProductionAcceptanceService +from .production_preflight import ProductionPreflightService +from .production_handshake_pack import ProductionHandshakePackService +from .production_launch_week_pack import ProductionLaunchWeekPackService +from .production_launch_ledger import ProductionLaunchLedgerService +from .production_signoff_board import ProductionSignoffBoardService +from .production_signoff import ProductionSignoffService +from .wave_activation_controller import WaveActivationControllerService +from ..persistence.repositories import SQLAlchemyPlatformRepository + + +class OpsCommercializationDashboardService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + customer_account_service: CustomerAccountService, + commercial_support_service: CommercialSupportService, + partner_readiness_service: PartnerReadinessService, + commercial_lifecycle_automation_service: CommercialLifecycleAutomationService, + production_signoff_service: Optional[ProductionSignoffService] = None, + production_signoff_board_service: Optional[ProductionSignoffBoardService] = None, + production_preflight_service: Optional[ProductionPreflightService] = None, + production_acceptance_service: Optional[ProductionAcceptanceService] = None, + launch_command_center_service: Optional[LaunchCommandCenterService] = None, + customer_success_reporting_service: Optional[CustomerSuccessReportingService] = None, + production_launch_ledger_service: Optional[ProductionLaunchLedgerService] = None, + human_signoff_closure_service: Optional[HumanSignoffClosureService] = None, + wave_activation_controller_service: Optional[WaveActivationControllerService] = None, + go_live_day_runner_service: Optional[GoLiveDayRunnerService] = None, + launch_week_guard_service: Optional[LaunchWeekGuardService] = None, + launch_week_monitoring_service: Optional[LaunchWeekMonitoringService] = None, + production_launch_week_pack_service: Optional[ProductionLaunchWeekPackService] = None, + production_handshake_pack_service: Optional[ProductionHandshakePackService] = None, + ) -> None: + self.repository = repository + self.customer_accounts = customer_account_service + self.commercial_support = commercial_support_service + self.partner_readiness = partner_readiness_service + self.commercial_lifecycle = commercial_lifecycle_automation_service + self.production_signoff = production_signoff_service + self.production_signoff_board = production_signoff_board_service + self.production_preflight = production_preflight_service + self.production_acceptance = production_acceptance_service + self.launch_command_center = launch_command_center_service + self.customer_success = customer_success_reporting_service + self.launch_ledger = production_launch_ledger_service + self.human_signoff_closure = human_signoff_closure_service + self.wave_activation = wave_activation_controller_service + self.go_live_day_runner = go_live_day_runner_service + self.launch_week_guard = launch_week_guard_service + self.launch_week_monitoring = launch_week_monitoring_service + self.launch_week_pack = production_launch_week_pack_service + self.handshake_pack = production_handshake_pack_service + + def _utcnow(self): + from datetime import datetime, timezone + + return datetime.now(timezone.utc) + + def _threshold(self, env_key: str, default: int) -> int: + try: + return max(1, int(os.getenv(env_key, str(default)))) + except ValueError: + return default + + def _launch_week_alert( + self, + *, + alert_key: str, + severity: str, + owner_role: str, + summary: str, + count: int, + account_ids: Optional[List[str]] = None, + invoice_ids: Optional[List[str]] = None, + payment_transaction_ids: Optional[List[str]] = None, + webhook_event_ids: Optional[List[str]] = None, + support_case_ids: Optional[List[str]] = None, + dispute_ids: Optional[List[str]] = None, + recommended_actions: Optional[List[str]] = None, + ) -> Dict[str, Any]: + return { + "alert_key": alert_key, + "severity": severity, + "owner_role": owner_role, + "count": count, + "summary": summary, + "account_ids": list(account_ids or []), + "invoice_ids": list(invoice_ids or []), + "payment_transaction_ids": list(payment_transaction_ids or []), + "webhook_event_ids": list(webhook_event_ids or []), + "support_case_ids": list(support_case_ids or []), + "dispute_ids": list(dispute_ids or []), + "recommended_actions": list(recommended_actions or []), + "drilldown_refs": [ + item + for item in [ + *[ + {"kind": "account", "id": value, "label": value} + for value in list(account_ids or [])[:5] + ], + *[ + {"kind": "invoice", "id": value, "label": value} + for value in list(invoice_ids or [])[:5] + ], + *[ + {"kind": "payment_transaction", "id": value, "label": value} + for value in list(payment_transaction_ids or [])[:5] + ], + *[ + {"kind": "provider_webhook_event", "id": value, "label": value} + for value in list(webhook_event_ids or [])[:5] + ], + *[ + {"kind": "support_case", "id": value, "label": value} + for value in list(support_case_ids or [])[:5] + ], + *[ + {"kind": "dispute", "id": value, "label": value} + for value in list(dispute_ids or [])[:5] + ], + ] + if item.get("id") + ], + } + + def _launch_week_alert_pack( + self, + *, + invoice_issuances: List[Dict[str, Any]], + payment_transactions: List[Dict[str, Any]], + provider_webhook_events: List[Dict[str, Any]], + dunning_runs: List[Dict[str, Any]], + disputes: List[Dict[str, Any]], + support_cases: List[Dict[str, Any]], + customers: List[Dict[str, Any]], + active_overage_flags: List[Dict[str, Any]], + recommended_expansions: List[Dict[str, Any]], + ) -> Dict[str, Any]: + alerts: List[Dict[str, Any]] = [] + payment_failures = [item for item in payment_transactions if str(item.get("status") or "") == "failed"] + webhook_failures = [item for item in provider_webhook_events if str(item.get("status") or "") != "processed"] + invoice_failures = [ + item + for item in invoice_issuances + if str(item.get("status") or "") in {"failed", "void"} + or (str(item.get("status") or "") in {"draft", "issued"} and (not item.get("hosted_invoice_url") or not item.get("invoice_pdf_url"))) + ] + open_dunning_runs = [item for item in dunning_runs if str(item.get("status") or "") == "open"] + dispute_backlog = [item for item in disputes if str(item.get("status") or "") in {"open", "under_review", "approved"}] + support_backlog = [item for item in support_cases if str(item.get("status") or "") in {"open", "in_progress"}] + recommended_expansion_accounts = {str(item.get("account_id") or "") for item in recommended_expansions if str(item.get("account_id") or "")} + open_dunning_accounts = {str(item.get("account_id") or "") for item in open_dunning_runs if str(item.get("account_id") or "")} + renewal_due_no_action = [ + item + for item in customers + if str(item.get("status") or "") == "renewal_due" + and str(item.get("account_id") or "") not in open_dunning_accounts + and str(item.get("account_id") or "") not in recommended_expansion_accounts + ] + overage_without_upgrade = [ + item for item in recommended_expansions if str(item.get("status") or "") == "recommended" + ] + + if len(payment_failures) >= self._threshold("NARRATIVEOS_LAUNCH_ALERT_PAYMENT_FAILURE_THRESHOLD", 1): + alerts.append( + self._launch_week_alert( + alert_key="payment_failures", + severity="high", + owner_role="stripe_owner", + summary=f"发现 {len(payment_failures)} 笔支付失败,需确认客户重试与催缴路径。", + count=len(payment_failures), + account_ids=[str(item.get("account_id") or "") for item in payment_failures if item.get("account_id")], + invoice_ids=[str(item.get("invoice_id") or "") for item in payment_failures if item.get("invoice_id")], + payment_transaction_ids=[str(item.get("payment_transaction_id") or "") for item in payment_failures if item.get("payment_transaction_id")], + recommended_actions=["review_customer_invoice", "follow_up_dunning", "verify_payment_retry"], + ) + ) + if len(webhook_failures) >= self._threshold("NARRATIVEOS_LAUNCH_ALERT_WEBHOOK_FAILURE_THRESHOLD", 1): + alerts.append( + self._launch_week_alert( + alert_key="webhook_failures", + severity="critical", + owner_role="infra_owner", + summary=f"发现 {len(webhook_failures)} 条 provider webhook 未完成处理。", + count=len(webhook_failures), + account_ids=[str(item.get("account_id") or "") for item in webhook_failures if item.get("account_id")], + invoice_ids=[str(item.get("invoice_id") or "") for item in webhook_failures if item.get("invoice_id")], + webhook_event_ids=[str(item.get("provider_webhook_event_id") or "") for item in webhook_failures if item.get("provider_webhook_event_id")], + recommended_actions=["replay_provider_webhook", "inspect_webhook_processing", "confirm_webhook_endpoint"], + ) + ) + if len(invoice_failures) >= self._threshold("NARRATIVEOS_LAUNCH_ALERT_INVOICE_FAILURE_THRESHOLD", 1): + alerts.append( + self._launch_week_alert( + alert_key="invoice_issuance_failures", + severity="high", + owner_role="stripe_owner", + summary=f"发现 {len(invoice_failures)} 条 invoice issuance 异常或缺失 hosted/pdf link。", + count=len(invoice_failures), + account_ids=[str(item.get("account_id") or "") for item in invoice_failures if item.get("account_id")], + invoice_ids=[str(item.get("invoice_id") or "") for item in invoice_failures if item.get("invoice_id")], + recommended_actions=["inspect_invoice_issuance", "compare_invoice_preview", "verify_hosted_invoice_links"], + ) + ) + if len(open_dunning_runs) >= self._threshold("NARRATIVEOS_LAUNCH_ALERT_DUNNING_SPIKE_THRESHOLD", 2): + alerts.append( + self._launch_week_alert( + alert_key="dunning_spikes", + severity="medium", + owner_role="support_finance_owner", + summary=f"当前有 {len(open_dunning_runs)} 条 open dunning run,需确认 follow-up 节奏。", + count=len(open_dunning_runs), + account_ids=[str(item.get("account_id") or "") for item in open_dunning_runs if item.get("account_id")], + invoice_ids=[str(item.get("invoice_id") or "") for item in open_dunning_runs if item.get("invoice_id")], + recommended_actions=["review_dunning_queue", "follow_up_customer", "verify_retry_schedule"], + ) + ) + if len(dispute_backlog) >= self._threshold("NARRATIVEOS_LAUNCH_ALERT_DISPUTE_SPIKE_THRESHOLD", 2): + alerts.append( + self._launch_week_alert( + alert_key="dispute_spikes", + severity="medium", + owner_role="support_finance_owner", + summary=f"当前有 {len(dispute_backlog)} 条未结 disputes。", + count=len(dispute_backlog), + account_ids=[str(item.get("account_id") or "") for item in dispute_backlog if item.get("account_id")], + dispute_ids=[str(item.get("dispute_id") or "") for item in dispute_backlog if item.get("dispute_id")], + recommended_actions=["review_disputes", "coordinate_finance", "inspect_customer_history"], + ) + ) + if len(support_backlog) >= self._threshold("NARRATIVEOS_LAUNCH_ALERT_SUPPORT_BACKLOG_THRESHOLD", 2): + alerts.append( + self._launch_week_alert( + alert_key="support_backlog", + severity="medium", + owner_role="support_finance_owner", + summary=f"当前有 {len(support_backlog)} 条 open/in_progress support cases。", + count=len(support_backlog), + account_ids=[str(item.get("account_id") or "") for item in support_backlog if item.get("account_id")], + support_case_ids=[str(item.get("support_case_id") or "") for item in support_backlog if item.get("support_case_id")], + recommended_actions=["review_support_cases", "assign_case_owner", "inspect_account_workspace"], + ) + ) + if len(renewal_due_no_action) >= self._threshold("NARRATIVEOS_LAUNCH_ALERT_RENEWAL_NO_ACTION_THRESHOLD", 1): + alerts.append( + self._launch_week_alert( + alert_key="renewal_due_no_action", + severity="high", + owner_role="stripe_owner", + summary=f"发现 {len(renewal_due_no_action)} 个 renewal_due 账户尚未挂上 dunning 或升级动作。", + count=len(renewal_due_no_action), + account_ids=[str(item.get("account_id") or "") for item in renewal_due_no_action if item.get("account_id")], + recommended_actions=["inspect_customer_workspace", "create_cutover_followup", "assign_owner"], + ) + ) + if len(overage_without_upgrade) >= self._threshold("NARRATIVEOS_LAUNCH_ALERT_OVERAGE_NO_UPGRADE_THRESHOLD", 1): + alerts.append( + self._launch_week_alert( + alert_key="overage_without_upgrade_follow_up", + severity="medium", + owner_role="support_finance_owner", + summary=f"发现 {len(overage_without_upgrade)} 个账户存在 overage 且仍停留在 recommended upgrade。", + count=len(overage_without_upgrade), + account_ids=[str(item.get("account_id") or "") for item in overage_without_upgrade if item.get("account_id")], + recommended_actions=["review_upgrade_followup", "contact_customer", "confirm_plan_change"], + ) + ) + + alerts.sort(key=lambda item: ({"critical": 0, "high": 1, "medium": 2, "low": 3}.get(item["severity"], 4), -int(item.get("count") or 0), item["alert_key"])) + return { + "generated_at": self._utcnow().isoformat(), + "summary": { + "alert_count": len(alerts), + "by_alert_key": dict(Counter(item["alert_key"] for item in alerts)), + "by_severity": dict(Counter(item["severity"] for item in alerts)), + "by_owner_role": dict(Counter(item["owner_role"] for item in alerts)), + }, + "alerts": alerts[:20], + } + + def _merge_launch_week_monitoring_alerts(self, alert_pack: Dict[str, Any]) -> Dict[str, Any]: + if self.launch_week_monitoring is None: + return alert_pack + monitoring_pack = self.launch_week_monitoring.current_alert_pack( + public_app_url=os.getenv("PUBLIC_APP_URL", DEFAULT_PUBLIC_APP_URL), + performance_summary_path=os.getenv("NARRATIVEOS_VERCEL_PERFORMANCE_SUMMARY"), + ) + alerts = list(alert_pack.get("alerts") or []) + list(monitoring_pack.get("alerts") or []) + alerts.sort(key=lambda item: ({"critical": 0, "high": 1, "medium": 2, "low": 3}.get(str(item.get("severity") or ""), 4), -int(item.get("count") or 0), str(item.get("alert_key") or ""))) + return { + **alert_pack, + "summary": { + **dict(alert_pack.get("summary") or {}), + "alert_count": len(alerts), + "by_alert_key": dict(Counter(str(item.get("alert_key") or "unknown") for item in alerts)), + "by_severity": dict(Counter(str(item.get("severity") or "unknown") for item in alerts)), + "by_owner_role": dict(Counter(str(item.get("owner_role") or "unknown") for item in alerts)), + "launch_week_monitoring_ready_to_expand": bool((monitoring_pack.get("summary") or {}).get("ready_to_expand", True)), + "launch_week_monitoring": dict(monitoring_pack.get("monitoring_summary") or {}), + }, + "alerts": alerts[:20], + } + + def launch_week_alert_pack(self, *, limit: int = 50) -> Dict[str, Any]: + customers = self.repository.list_customer_accounts(limit=500) + support_cases = self.repository.list_support_cases(limit=500) + disputes = self.repository.list_disputes(limit=500) + overage_flags = self.repository.list_overage_flags(limit=500) + invoice_issuances = self.repository.list_invoice_issuances(limit=500) + payment_transactions = self.repository.list_payment_transactions(limit=1000) + provider_webhook_events = self.repository.list_provider_webhook_events(limit=500) + for item in customers[:]: + self.commercial_lifecycle.sync_account(account_id=item["account_id"]) + dunning_runs = self.repository.list_dunning_runs(limit=500) + expansion_candidates = self.repository.list_expansion_candidates(limit=500) + active_overage_flags = [item for item in overage_flags if str(item.get("status") or "") == "active"] + recommended_expansions = [item for item in expansion_candidates if str(item.get("status") or "") == "recommended"] + return self._merge_launch_week_monitoring_alerts(self._launch_week_alert_pack( + invoice_issuances=invoice_issuances, + payment_transactions=payment_transactions, + provider_webhook_events=provider_webhook_events, + dunning_runs=dunning_runs, + disputes=disputes, + support_cases=support_cases, + customers=customers, + active_overage_flags=active_overage_flags, + recommended_expansions=recommended_expansions, + )) + + def summary(self, *, limit: int = 50) -> Dict[str, Any]: + customers = self.repository.list_customer_accounts(limit=500) + invoice_previews = self.repository.list_invoice_previews(limit=500) + billable_events = self.repository.list_billable_events(limit=1000) + overage_flags = self.repository.list_overage_flags(limit=500) + disputes = self.repository.list_disputes(limit=500) + support_cases = self.repository.list_support_cases(limit=500) + partners = self.partner_readiness.list_partners(limit=200) + invoice_issuances = self.repository.list_invoice_issuances(limit=500) + payment_transactions = self.repository.list_payment_transactions(limit=1000) + provider_webhook_events = self.repository.list_provider_webhook_events(limit=500) + + pilot_accounts = [item for item in customers if str(item.get("status") or "") == "trial"] + paid_accounts = [item for item in customers if str(item.get("status") or "") in {"active", "paused", "renewal_due"}] + renewal_due_accounts = [item for item in customers if str(item.get("status") or "") == "renewal_due"] + churn_risk_accounts = [ + item + for item in customers + if str(item.get("status") or "") in {"paused", "renewal_due"} + or any(dispute.get("account_id") == item.get("account_id") and str(dispute.get("status") or "") in {"open", "approved"} for dispute in disputes) + ] + unpaid_invoice_previews = [item for item in invoice_previews if float(item.get("total_due_usd") or 0.0) > 0.0] + disputed_events = [item for item in billable_events if str(item.get("status") or "") == "disputed"] + credited_events = [item for item in billable_events if str(item.get("status") or "") == "credited"] + support_backlog = [item for item in support_cases if str(item.get("status") or "") in {"open", "in_progress"}] + dispute_backlog = [item for item in disputes if str(item.get("status") or "") in {"open", "under_review", "approved"}] + active_overage_flags = [item for item in overage_flags if str(item.get("status") or "") == "active"] + for item in customers[:]: + self.commercial_lifecycle.sync_account(account_id=item["account_id"]) + renewal_trackers = self.repository.list_renewal_trackers(limit=500) + dunning_runs = self.repository.list_dunning_runs(limit=500) + pilot_tracks = self.repository.list_pilot_conversion_tracks(limit=500) + expansion_candidates = self.repository.list_expansion_candidates(limit=500) + churn_flags = self.repository.list_churn_risk_flags(limit=500) + open_dunning_runs = [item for item in dunning_runs if str(item.get("status") or "") == "open"] + recommended_expansions = [item for item in expansion_candidates if str(item.get("status") or "") == "recommended"] + watch_churn_flags = [item for item in churn_flags if str(item.get("status") or "") == "watch"] + ready_pilot_tracks = [item for item in pilot_tracks if str(item.get("status") or "") == "ready_for_conversion"] + + launch_week_alert_pack = self._merge_launch_week_monitoring_alerts(self._launch_week_alert_pack( + invoice_issuances=invoice_issuances, + payment_transactions=payment_transactions, + provider_webhook_events=provider_webhook_events, + dunning_runs=dunning_runs, + disputes=disputes, + support_cases=support_cases, + customers=customers, + active_overage_flags=active_overage_flags, + recommended_expansions=recommended_expansions, + )) + + return { + "pilot_vs_paid": { + "pilot_account_count": len(pilot_accounts), + "paid_account_count": len(paid_accounts), + "pilot_account_ids": [item.get("account_id") for item in pilot_accounts[:limit]], + "paid_account_ids": [item.get("account_id") for item in paid_accounts[:limit]], + }, + "invoice_preview_totals": { + "preview_count": len(invoice_previews), + "subtotal_amount_usd": round(sum(float(item.get("subtotal_amount_usd") or 0.0) for item in invoice_previews), 6), + "total_due_usd": round(sum(float(item.get("total_due_usd") or 0.0) for item in invoice_previews), 6), + "invoice_preview_ids": [item.get("invoice_preview_id") for item in invoice_previews[:limit]], + }, + "financial_status_totals": { + "unpaid_count": len(unpaid_invoice_previews), + "disputed_count": len(disputed_events), + "credited_count": len(credited_events), + "unpaid_invoice_preview_ids": [item.get("invoice_preview_id") for item in unpaid_invoice_previews[:limit]], + "disputed_billable_event_ids": [item.get("billable_event_id") for item in disputed_events[:limit]], + "credited_billable_event_ids": [item.get("billable_event_id") for item in credited_events[:limit]], + }, + "overage_totals": { + "active_flag_count": len(active_overage_flags), + "by_metric": dict(Counter(str(item.get("metric_type") or "unknown") for item in active_overage_flags)), + "overage_flag_ids": [item.get("overage_flag_id") for item in active_overage_flags[:limit]], + }, + "renewal_due_accounts": { + "count": len([item for item in renewal_trackers if str(item.get("status") or "") == "renewal_due"]), + "account_ids": [item.get("account_id") for item in renewal_trackers if str(item.get("status") or "") == "renewal_due"][:limit], + }, + "dunning_runs": { + "count": len(open_dunning_runs), + "account_ids": [item.get("account_id") for item in open_dunning_runs[:limit]], + "invoice_ids": [item.get("invoice_id") for item in open_dunning_runs[:limit]], + "step_counts": dict(Counter(str(item.get("current_step") or "unknown") for item in open_dunning_runs)), + }, + "pilot_conversion": { + "ready_count": len(ready_pilot_tracks), + "converted_count": len([item for item in pilot_tracks if str(item.get("status") or "") == "converted"]), + "watch_count": len([item for item in pilot_tracks if str(item.get("status") or "") == "watch"]), + "account_ids": [item.get("account_id") for item in ready_pilot_tracks[:limit]], + }, + "expansion_candidates": { + "recommended_count": len(recommended_expansions), + "account_ids": [item.get("account_id") for item in recommended_expansions[:limit]], + "by_trigger": dict(Counter(str(item.get("trigger_type") or "unknown") for item in recommended_expansions)), + }, + "churn_risk_accounts": { + "count": len(watch_churn_flags), + "account_ids": [item.get("account_id") for item in watch_churn_flags[:limit]], + "risk_levels": dict(Counter(str(item.get("risk_level") or "unknown") for item in watch_churn_flags)), + }, + "partner_readiness_heatmap": { + "lifecycle_counts": dict((partners.get("summary") or {}).get("lifecycle_counts") or {}), + "health_counts": dict((partners.get("summary") or {}).get("health_counts") or {}), + "partner_ids": [item["partner"].get("partner_id") for item in (partners.get("partners") or [])[:limit]], + }, + "support_backlog": { + "count": len(support_backlog), + "support_case_ids": [item.get("support_case_id") for item in support_backlog[:limit]], + }, + "dispute_backlog": { + "count": len(dispute_backlog), + "dispute_ids": [item.get("dispute_id") for item in dispute_backlog[:limit]], + }, + "lifecycle_automation": { + "dunning_run_count": len(open_dunning_runs), + "pilot_ready_count": len(ready_pilot_tracks), + "expansion_candidate_count": len(recommended_expansions), + "churn_watch_count": len(watch_churn_flags), + }, + "production_signoff": self.production_signoff.current_signoff_summary() if self.production_signoff else None, + "production_signoff_action_board": self.production_signoff_board.current_board_summary() if self.production_signoff_board else None, + "production_acceptance": ( + self.production_acceptance.list_acceptance_records(limit=limit).get("summary") + if self.production_acceptance + else None + ), + "production_preflight_summary": self.production_preflight.list_runs(limit=limit).get("summary") if self.production_preflight else None, + "launch_week_alert_pack": launch_week_alert_pack, + "launch_week_ops_pack": self.launch_week_pack.current_pack_summary( + signoff_summary=self.production_signoff.current_signoff_summary() if self.production_signoff else None, + acceptance_summary=( + self.production_acceptance.list_acceptance_records(limit=limit).get("summary") + if self.production_acceptance + else None + ), + launch_week_alert_pack=launch_week_alert_pack, + ) + if self.launch_week_pack + else None, + "launch_handshake_pack": self.handshake_pack.current_pack_summary( + signoff_summary=self.production_signoff.current_signoff_summary() if self.production_signoff else None, + acceptance_summary=( + self.production_acceptance.list_acceptance_records(limit=limit).get("summary") + if self.production_acceptance + else None + ), + ) + if self.handshake_pack + else None, + "launch_command_center_summary": self.launch_command_center.command_center().get("summary") if self.launch_command_center else None, + "customer_success_summary": self.customer_success.list_customer_success(limit=limit).get("summary") if self.customer_success else None, + "launch_ledger_summary": self.launch_ledger.list_events(limit=limit).get("summary") if self.launch_ledger else None, + "human_signoff_closure_summary": self.human_signoff_closure.current_summary() if self.human_signoff_closure else None, + "wave_activation_summary": self.wave_activation.summary().get("summary") if self.wave_activation else None, + "go_live_day_summary": self.go_live_day_runner.summary().get("summary") if self.go_live_day_runner else None, + "launch_week_guard_summary": self.launch_week_guard.list_runs().get("summary") if self.launch_week_guard else None, + } diff --git a/src/narrativeos/services/ops_permissions.py b/src/narrativeos/services/ops_permissions.py new file mode 100644 index 0000000..d022a17 --- /dev/null +++ b/src/narrativeos/services/ops_permissions.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from dataclasses import dataclass +import re +from typing import Iterable, Optional + + +PRIVILEGED_OPS_ROLES = frozenset({"reviewer", "ops", "admin"}) +ADMIN_ONLY_OPS_ROLES = frozenset({"admin"}) + + +@dataclass(frozen=True) +class OpsPermissionRule: + methods: frozenset[str] + pattern: re.Pattern[str] + allowed_roles: frozenset[str] + missing_reason: str + forbidden_reason: str + + def matches(self, *, method: str, path: str) -> bool: + return method.upper() in self.methods and bool(self.pattern.match(path)) + + +class OpsPermissionPolicyService: + def __init__(self) -> None: + self.read_roles = PRIVILEGED_OPS_ROLES + self.write_roles = PRIVILEGED_OPS_ROLES + self._write_rules: tuple[OpsPermissionRule, ...] = ( + OpsPermissionRule( + methods=frozenset({"POST"}), + pattern=re.compile(r"^/v1/ops/runtime-restore/[^/]+/(approve|revoke)$"), + allowed_roles=ADMIN_ONLY_OPS_ROLES, + missing_reason="restore_admin_identity_required", + forbidden_reason="restore_admin_required", + ), + OpsPermissionRule( + methods=frozenset({"POST"}), + pattern=re.compile(r"^/v1/ops/jobs/runtime-restores$"), + allowed_roles=ADMIN_ONLY_OPS_ROLES, + missing_reason="restore_admin_identity_required", + forbidden_reason="restore_admin_required", + ), + OpsPermissionRule( + methods=frozenset({"POST"}), + pattern=re.compile(r"^/v1/ops/runtime-restore$"), + allowed_roles=ADMIN_ONLY_OPS_ROLES, + missing_reason="restore_admin_identity_required", + forbidden_reason="restore_admin_required", + ), + ) + + def _normalize_role(self, actor_role: Optional[str]) -> str: + return str(actor_role or "").strip() + + def _authorize_roles( + self, + *, + actor_id: Optional[str], + actor_role: Optional[str], + allowed_roles: Iterable[str], + missing_reason: str, + forbidden_reason: str, + ) -> dict[str, Optional[str]]: + resolved_actor_id = str(actor_id or "").strip() + resolved_actor_role = self._normalize_role(actor_role) + if not resolved_actor_id: + raise PermissionError(missing_reason) + if resolved_actor_role not in set(allowed_roles): + raise PermissionError(forbidden_reason) + return { + "actor_id": resolved_actor_id, + "actor_role": resolved_actor_role, + } + + def authorize_roles( + self, + *, + actor_id: Optional[str], + actor_role: Optional[str], + allowed_roles: Iterable[str], + missing_reason: str, + forbidden_reason: str, + ) -> dict[str, Optional[str]]: + return self._authorize_roles( + actor_id=actor_id, + actor_role=actor_role, + allowed_roles=allowed_roles, + missing_reason=missing_reason, + forbidden_reason=forbidden_reason, + ) + + def authorize_read( + self, + *, + actor_id: Optional[str], + actor_role: Optional[str], + ) -> dict[str, Optional[str]]: + return self._authorize_roles( + actor_id=actor_id, + actor_role=actor_role, + allowed_roles=self.read_roles, + missing_reason="ops_view_identity_required", + forbidden_reason="ops_view_role_forbidden", + ) + + def resolve_write_rule(self, *, method: str, path: str) -> OpsPermissionRule: + normalized_method = method.upper() + normalized_path = path.rstrip("/") or path + for rule in self._write_rules: + if rule.matches(method=normalized_method, path=normalized_path): + return rule + return OpsPermissionRule( + methods=frozenset({normalized_method}), + pattern=re.compile(r".*"), + allowed_roles=self.write_roles, + missing_reason="ops_mutation_identity_required", + forbidden_reason="ops_mutation_role_forbidden", + ) + + def authorize_write( + self, + *, + actor_id: Optional[str], + actor_role: Optional[str], + method: str, + path: str, + ) -> dict[str, Optional[str]]: + rule = self.resolve_write_rule(method=method, path=path) + return self._authorize_roles( + actor_id=actor_id, + actor_role=actor_role, + allowed_roles=rule.allowed_roles, + missing_reason=rule.missing_reason, + forbidden_reason=rule.forbidden_reason, + ) diff --git a/src/narrativeos/services/ops_quality_projection.py b/src/narrativeos/services/ops_quality_projection.py new file mode 100644 index 0000000..cd60e79 --- /dev/null +++ b/src/narrativeos/services/ops_quality_projection.py @@ -0,0 +1,472 @@ +from __future__ import annotations + +from collections import Counter +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from ..persistence.repositories import SQLAlchemyPlatformRepository + + +SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2, "info": 3} + + +class OpsQualityProjectionService: + def __init__(self, repository: SQLAlchemyPlatformRepository) -> None: + self.repository = repository + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _safe_world_version_author(self, world_version_id: Optional[str]) -> Optional[str]: + value = str(world_version_id or "").strip() + if not value: + return None + try: + return self.repository.get_world_version(value).author_id + except KeyError: + return None + + def _safe_world_id(self, world_version_id: Optional[str]) -> Optional[str]: + value = str(world_version_id or "").strip() + if not value: + return None + try: + return self.repository.get_world_version(value).world_id + except KeyError: + return None + + def _safe_session_reader(self, session_id: Optional[str]) -> Optional[str]: + value = str(session_id or "").strip() + if not value: + return None + try: + session = self.repository.get_session(value) + except KeyError: + return None + return str(session.player_profile.get("reader_id") or "").strip() or None + + def infer_account_id( + self, + *, + source_ref: Optional[Dict[str, Any]] = None, + world_version_id: Optional[str] = None, + session_id: Optional[str] = None, + ) -> Optional[str]: + source_ref = dict(source_ref or {}) + candidates = [ + str(source_ref.get("account_id") or "").strip(), + self._safe_session_reader(session_id), + self._safe_world_version_author(world_version_id), + ] + for item in candidates: + if item: + return item + return None + + def _score_lookup(self, *, trace_id: Optional[str]) -> Optional[Dict[str, Any]]: + if not str(trace_id or "").strip(): + return None + scores = self.repository.list_content_quality_scores(trace_id=str(trace_id), limit=1) + return scores[0] if scores else None + + def _review_case_lookup(self, *, trace_id: Optional[str]) -> Optional[Dict[str, Any]]: + if not str(trace_id or "").strip(): + return None + cases = self.repository.list_review_cases(trace_id=str(trace_id), limit=1) + return cases[0] if cases else None + + def _grounding_check_lookup(self, *, trace_id: Optional[str]) -> Optional[Dict[str, Any]]: + if not str(trace_id or "").strip(): + return None + checks = self.repository.list_grounding_checks(trace_id=str(trace_id), limit=1) + return checks[0] if checks else None + + def list_projected_quality_events( + self, + *, + account_id: Optional[str] = None, + world_version_id: Optional[str] = None, + session_id: Optional[str] = None, + source_surface: Optional[str] = None, + status: Optional[str] = None, + limit: int = 50, + ) -> List[Dict[str, Any]]: + events = self.repository.list_quality_events( + world_version_id=world_version_id, + session_id=session_id, + source_surface=source_surface, + status=status, + limit=max(limit * 3, limit), + ) + projected: List[Dict[str, Any]] = [] + for event in events: + inferred_account_id = self.infer_account_id( + source_ref=event.get("source_ref"), + world_version_id=event.get("world_version_id"), + session_id=event.get("session_id"), + ) + if account_id and inferred_account_id != account_id: + continue + score = self._score_lookup(trace_id=event.get("trace_id")) + review_case = self._review_case_lookup(trace_id=event.get("trace_id")) + grounding_check = self._grounding_check_lookup(trace_id=event.get("trace_id")) + reason_codes = [] + for source in [ + list((grounding_check or {}).get("reason_codes") or []), + list((score or {}).get("reason_codes") or []), + list((review_case or {}).get("reason_codes") or []), + list(dict(event.get("payload") or {}).get("reason_codes") or []), + ]: + for item in source: + normalized = str(item or "").strip() + if normalized and normalized not in reason_codes: + reason_codes.append(normalized) + projected.append( + { + **event, + "account_id": inferred_account_id, + "world_id": self._safe_world_id(event.get("world_version_id")), + "reason_codes": reason_codes, + "overall_score": (score or {}).get("overall_score"), + "veto": (score or {}).get("veto"), + "grounding_status": (grounding_check or {}).get("status"), + "review_case_id": (review_case or {}).get("case_id"), + "review_case_status": (review_case or {}).get("status"), + "review_case_owner_id": (review_case or {}).get("owner_id"), + } + ) + if len(projected) >= limit: + break + return projected + + def quality_summary( + self, + *, + account_id: Optional[str] = None, + world_version_id: Optional[str] = None, + session_id: Optional[str] = None, + source_surface: Optional[str] = None, + status: Optional[str] = None, + limit: int = 50, + ) -> Dict[str, Any]: + events = self.list_projected_quality_events( + account_id=account_id, + world_version_id=world_version_id, + session_id=session_id, + source_surface=source_surface, + status=status, + limit=limit, + ) + cases = self.repository.list_review_cases( + world_version_id=world_version_id, + session_id=session_id, + trace_id=None, + limit=max(limit * 3, limit), + ) + projected_cases = [] + for case in cases: + inferred_account_id = self.infer_account_id( + source_ref=case.get("source_ref"), + world_version_id=case.get("world_version_id"), + session_id=case.get("session_id"), + ) + if account_id and inferred_account_id != account_id: + continue + if source_surface and str(case.get("source_surface") or "") != str(source_surface): + continue + if status and str(case.get("status") or "") != str(status): + continue + projected_cases.append({**case, "account_id": inferred_account_id}) + if len(projected_cases) >= limit: + break + + by_status = Counter(str(item.get("status") or "unknown") for item in events) + by_surface = Counter(str(item.get("source_surface") or "unknown") for item in events) + grounding_checks = self.repository.list_grounding_checks( + world_version_id=world_version_id, + session_id=session_id, + limit=max(limit * 3, limit), + ) + top_reason_codes = Counter( + code + for item in events + for code in list(item.get("reason_codes") or []) + if str(code or "").strip() + ) + latest_trace = events[0].get("trace_id") if events else None + feedback_items = self.list_projected_quality_feedback_items( + account_id=account_id, + world_version_id=world_version_id, + session_id=session_id, + limit=limit, + ) + top_feedback_types = Counter( + str(item.get("feedback_type") or "unknown") + for item in feedback_items + ) + explicit_feedback = [item for item in feedback_items if str(item.get("feedback_type") or "") == "explicit_user_feedback"] + implicit_feedback = [item for item in feedback_items if str(item.get("feedback_type") or "") != "explicit_user_feedback"] + explicit_reason_codes = Counter( + str(dict(item.get("payload") or {}).get("reason_code") or "") + for item in explicit_feedback + if str(dict(item.get("payload") or {}).get("reason_code") or "").strip() + ) + return { + "generated_at": self._utcnow(), + "filters": { + "account_id": account_id, + "world_version_id": world_version_id, + "session_id": session_id, + "source_surface": source_surface, + "status": status, + "limit": limit, + }, + "summary": { + "event_count": len(events), + "review_case_count": len(projected_cases), + "open_review_case_count": sum(1 for item in projected_cases if item.get("status") in {"open", "in_review"}), + "blocked_event_count": sum(1 for item in events if item.get("status") == "blocked"), + "review_required_event_count": sum(1 for item in events if item.get("status") == "review_required"), + "grounding_check_count": len(grounding_checks), + "feedback_item_count": len(feedback_items), + "retry_signal_count": sum(1 for item in feedback_items if str(item.get("signal") or "") == "retry"), + "by_status": dict(by_status), + "by_source_surface": dict(by_surface), + "top_reason_codes": [ + {"reason_code": key, "count": count} + for key, count in top_reason_codes.most_common(5) + ], + "top_feedback_types": [ + {"feedback_type": key, "count": count} + for key, count in top_feedback_types.most_common(5) + ], + "latest_trace_id": latest_trace, + }, + "groundedness_summary": { + "pass_rate": round(sum(1 for item in grounding_checks if str(item.get("status") or "") == "passed") / float(max(1, len(grounding_checks))), 3) if grounding_checks else 0.0, + "weak_count": sum(1 for item in grounding_checks if str(item.get("status") or "") == "weak"), + "failed_count": sum(1 for item in grounding_checks if str(item.get("status") or "") == "failed"), + "unsupported_claim_count": sum(len(item.get("unsupported_claims") or []) for item in grounding_checks), + }, + "feedback_summary": { + "thumbs_up_count": sum(1 for item in explicit_feedback if str(item.get("signal") or "") == "explicit_positive"), + "thumbs_down_count": sum(1 for item in explicit_feedback if str(item.get("signal") or "") == "explicit_negative"), + "explicit_vs_implicit": { + "explicit": len(explicit_feedback), + "implicit": len(implicit_feedback), + }, + "top_reason_codes": [ + {"reason_code": key, "count": count} + for key, count in explicit_reason_codes.most_common(5) + ], + }, + "review_pressure": { + "new_review_cases": sum(1 for item in projected_cases if item.get("status") == "open"), + "unresolved_review_cases": sum(1 for item in projected_cases if item.get("status") in {"open", "in_review"}), + "top_review_reasons": [ + {"reason_code": key, "count": count} + for key, count in Counter( + code + for item in projected_cases + for code in list(item.get("reason_codes") or []) + if str(code or "").strip() + ).most_common(5) + ], + }, + "quality_trend": { + "score_trend": [ + {"trace_id": item.get("trace_id"), "overall_score": item.get("overall_score"), "created_at": item.get("created_at")} + for item in events[:12] + ], + "veto_trend": [ + {"trace_id": item.get("trace_id"), "veto": bool(item.get("veto")), "created_at": item.get("created_at")} + for item in events[:12] + ], + "guard_failed_trend": [ + {"trace_id": item.get("trace_id"), "status": item.get("status"), "created_at": item.get("created_at")} + for item in events[:12] + ], + }, + "events": events, + "review_cases": projected_cases, + "feedback_items": feedback_items[:10], + } + + def list_projected_quality_feedback_items( + self, + *, + account_id: Optional[str] = None, + world_version_id: Optional[str] = None, + session_id: Optional[str] = None, + trace_id: Optional[str] = None, + limit: int = 50, + ) -> List[Dict[str, Any]]: + items = self.repository.list_quality_feedback_items( + account_id=account_id, + world_version_id=world_version_id, + session_id=session_id, + trace_id=trace_id, + limit=limit, + ) + return [ + { + **item, + "account_id": item.get("account_id") or self.infer_account_id( + source_ref=item.get("source_ref"), + world_version_id=item.get("world_version_id"), + session_id=item.get("session_id"), + ), + "world_id": self._safe_world_id(item.get("world_version_id")), + } + for item in items + ] + + def quality_trace_detail(self, trace_id: str) -> Dict[str, Any]: + trace = str(trace_id or "").strip() + if not trace: + raise KeyError("unknown_quality_trace:") + events = self.repository.list_quality_events(trace_id=trace, limit=10) + if not events: + raise KeyError("unknown_quality_trace:%s" % trace) + event = events[0] + score = self._score_lookup(trace_id=trace) + review_case = self._review_case_lookup(trace_id=trace) + grounding_check = self._grounding_check_lookup(trace_id=trace) + account_id = self.infer_account_id( + source_ref=event.get("source_ref"), + world_version_id=event.get("world_version_id"), + session_id=event.get("session_id"), + ) + feedback_items = self.list_projected_quality_feedback_items(trace_id=trace, limit=20) + feedback_types = Counter(str(item.get("feedback_type") or "unknown") for item in feedback_items) + explicit_feedback = [item for item in feedback_items if str(item.get("feedback_type") or "") == "explicit_user_feedback"] + implicit_feedback = [item for item in feedback_items if str(item.get("feedback_type") or "") != "explicit_user_feedback"] + return { + "generated_at": self._utcnow(), + "trace_id": trace, + "event": { + **event, + "account_id": account_id, + "world_id": self._safe_world_id(event.get("world_version_id")), + }, + "score": score, + "grounding_check": grounding_check, + "review_case": review_case, + "linked_context": { + "account_id": account_id, + "world_id": self._safe_world_id(event.get("world_version_id")), + "world_version_id": event.get("world_version_id"), + "session_id": event.get("session_id"), + }, + "grounding_summary": { + "status": (grounding_check or {}).get("status"), + "confidence": (grounding_check or {}).get("confidence"), + "unsupported_claim_count": len((grounding_check or {}).get("unsupported_claims") or []), + "reason_codes": list((grounding_check or {}).get("reason_codes") or []), + }, + "feedback_summary": { + "feedback_item_count": len(feedback_items), + "retry_signal_count": sum(1 for item in feedback_items if str(item.get("signal") or "") == "retry"), + "by_feedback_type": dict(feedback_types), + }, + "explicit_feedback": explicit_feedback, + "implicit_feedback_summary": { + "count": len(implicit_feedback), + "by_feedback_type": dict(Counter(str(item.get("feedback_type") or "unknown") for item in implicit_feedback)), + }, + "feedback_timeline": feedback_items, + "feedback_items": feedback_items, + } + + def _case_severity(self, *, review_case: Dict[str, Any], quality_event: Optional[Dict[str, Any]], score: Optional[Dict[str, Any]]) -> str: + if str((quality_event or {}).get("status") or "") == "blocked" or bool((score or {}).get("veto")): + return "high" + if str((quality_event or {}).get("status") or "") == "review_required": + return "medium" + return "medium" + + def _priority(self, *, severity: str, status: str) -> int: + base = {"high": 10, "medium": 20, "low": 30, "info": 40}.get(str(severity or "medium"), 20) + if status == "new": + return base + if status == "in_review": + return base + 5 + if status in {"resolved", "dismissed"}: + return base + 90 + return base + 10 + + def build_quality_review_case_items(self) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + for case in self.repository.list_review_cases(limit=500): + trace_id = str(case.get("trace_id") or "") + event_rows = self.repository.list_quality_events(trace_id=trace_id, limit=1) if trace_id else [] + event = event_rows[0] if event_rows else None + score = self._score_lookup(trace_id=trace_id) + account_id = self.infer_account_id( + source_ref=case.get("source_ref"), + world_version_id=case.get("world_version_id"), + session_id=case.get("session_id"), + ) + world_id = self._safe_world_id(case.get("world_version_id")) + source_status = { + "open": "new", + "in_review": "in_review", + "resolved": "resolved", + "dismissed": "dismissed", + }.get(str(case.get("status") or "open"), "new") + severity = self._case_severity(review_case=case, quality_event=event, score=score) + reason_codes = list(case.get("reason_codes") or []) or list((score or {}).get("reason_codes") or []) + item = { + "review_item_id": f"ops_review::quality_review_case::{case['case_id']}", + "source_type": "quality_review_case", + "source_id": str(case["case_id"]), + "queue": "runtime", + "status": source_status, + "severity": severity, + "priority": self._priority(severity=severity, status=source_status), + "owner_id": case.get("owner_id"), + "reviewer_id": None, + "account_id": account_id, + "world_id": world_id, + "world_version_id": case.get("world_version_id"), + "headline": f"{str((event or {}).get('source_surface') or case.get('source_surface') or 'quality')} · 质量审阅", + "summary": f"status {str((event or {}).get('status') or '-')} · reasons {' / '.join(reason_codes[:3]) or '-'}", + "recommended_action": "open_investigation" if str((event or {}).get("status") or "") == "blocked" else "review_quality_case", + "due_at": None, + "sla_bucket": "backlog" if source_status not in {"resolved", "dismissed"} else "closed", + "allowed_actions": ["assign_to_me", "mark_in_review", "resolve", "dismiss", "open_account_workspace", "open_release_workspace", "open_investigation"], + "linked_entities": [ + item + for item in [ + {"kind": "account", "id": account_id, "label": account_id} if account_id else None, + {"kind": "world", "id": world_id, "label": world_id} if world_id else None, + {"kind": "world_version", "id": case.get("world_version_id"), "label": case.get("world_version_id")} if case.get("world_version_id") else None, + {"kind": "session", "id": case.get("session_id"), "label": case.get("session_id")} if case.get("session_id") else None, + {"kind": "trace", "id": trace_id, "label": trace_id} if trace_id else None, + ] + if item + ], + "source_updated_at": case.get("updated_at"), + "source_payload": { + "review_case": case, + "quality_event": event or {}, + "content_quality_score": score or {}, + "trace_summary": { + "trace_id": trace_id, + "source_surface": (event or {}).get("source_surface") or case.get("source_surface"), + "status": (event or {}).get("status"), + "reason_codes": reason_codes, + "overall_score": (score or {}).get("overall_score"), + "veto": (score or {}).get("veto"), + }, + }, + } + items.append(item) + items.sort( + key=lambda item: ( + int(item.get("priority", 100)), + SEVERITY_ORDER.get(str(item.get("severity") or "medium"), 9), + str(item.get("source_updated_at") or ""), + ) + ) + return items diff --git a/src/narrativeos/services/ops_release_workspace.py b/src/narrativeos/services/ops_release_workspace.py index b4f3058..8118c31 100644 --- a/src/narrativeos/services/ops_release_workspace.py +++ b/src/narrativeos/services/ops_release_workspace.py @@ -4,6 +4,7 @@ from typing import Any, Dict, List, Optional from ..persistence.repositories import SQLAlchemyPlatformRepository +from .ops_quality_projection import OpsQualityProjectionService from .ops_traceability import OpsTraceabilityService from .review import ReviewService @@ -15,10 +16,12 @@ def __init__( *, review_service: ReviewService, ops_traceability_service: OpsTraceabilityService, + quality_projection_service: Optional[OpsQualityProjectionService] = None, ) -> None: self.repository = repository self.review = review_service self.traceability = ops_traceability_service + self.quality_projection = quality_projection_service or OpsQualityProjectionService(repository) def _utcnow(self) -> str: return datetime.now(timezone.utc).isoformat() @@ -53,8 +56,12 @@ def _release_health_status( checklist = dict(status_payload.get("publish_checklist_summary") or {}) latest_trend = (history_payload.get("quality_trend") or [{}])[0] rollback_summary = dict(history_payload.get("rollback_summary") or {}) + release_bundle = dict(status_payload.get("release_evidence_bundle") or {}) + combined_signoff = dict(release_bundle.get("combined_signoff") or {}) if not checklist.get("publish_ready"): return "blocked" + if release_bundle and not bool(combined_signoff.get("ready", False)): + return "watch" if latest_trend.get("regression_detected") or int(rollback_summary.get("total_entries") or 0) > 0: return "watch" return "ready" @@ -67,8 +74,12 @@ def _recommended_action( selected_world_version_id: Optional[str], ) -> str: checklist_summary = dict(status_payload.get("publish_checklist_summary") or {}) + release_bundle = dict(status_payload.get("release_evidence_bundle") or {}) + combined_signoff = dict(release_bundle.get("combined_signoff") or {}) if not checklist_summary.get("publish_ready"): return (checklist_summary.get("next_actions") or ["inspect_publish_blockers"])[0] + if release_bundle and not bool(combined_signoff.get("ready", False)): + return "inspect_release_evidence_bundle" if selected_world_version_id and selected_world_version_id != status_payload.get("published_version"): return "publish_candidate" rollback_summary = dict(history_payload.get("rollback_summary") or {}) @@ -76,6 +87,14 @@ def _recommended_action( return "inspect_recent_rollback" return "observe_release_state" + def _strategy_bundle_batch_validation_status(self, release_bundle: Dict[str, Any]) -> str: + summary = dict(release_bundle.get("strategy_bundle_batch_validation_summary") or {}) + if not summary: + return "not_run" + if not bool(summary.get("available", False)): + return "not_run" + return str(summary.get("decision") or "not_run") + def _publish_blockers(self, status_payload: Dict[str, Any]) -> Dict[str, Any]: blockers = [item for item in status_payload.get("publish_checklist", []) if not item.get("ok")] by_owner: Dict[str, int] = {} @@ -85,11 +104,71 @@ def _publish_blockers(self, status_payload: Dict[str, Any]) -> Dict[str, Any]: severity = str(item.get("severity") or "unknown") by_owner[owner] = by_owner.get(owner, 0) + 1 by_severity[severity] = by_severity.get(severity, 0) + 1 + phase_a_gate_item = next((item for item in blockers if str(item.get("key") or "") == "phase_a_quality_gate"), None) + phase_a_evidence = dict((phase_a_gate_item or {}).get("evidence") or {}) + content_quality_gate_item = next((item for item in blockers if str(item.get("key") or "") == "content_quality_contract_gate"), None) + content_quality_evidence = dict((content_quality_gate_item or {}).get("evidence") or {}) + phase_a_check_labels = { + "cross_pack_pass_rate": "Cross-pack pass rate", + "weakest_pack_pass_rate": "Weakest pack pass rate", + "q03_weakest_issue_share": "Q03 weakest-pack share", + "q04_weakest_issue_share": "Q04 weakest-pack share", + "q05_weakest_issue_share": "Q05 weakest-pack share", + "q09_weakest_issue_share": "Q09 weakest-pack share", + } + content_quality_check_labels = { + "asset_contract_coverage": "Asset contract coverage", + "early_window_q03_q04_share": "Early window Q03/Q04 share", + "mid_window_repeat_breach_rate": "Mid window repetition breach rate", + "mid_window_exposition_breach_rate": "Mid window exposition breach rate", + "late_window_q09_breach_rate": "Late window Q09 breach rate", + } + phase_a_failed_check_items = [] + for check in phase_a_evidence.get("checks", []) or []: + payload = dict(check or {}) + if payload.get("ok") or payload.get("skipped"): + continue + phase_a_failed_check_items.append( + { + "check_key": str(payload.get("key") or ""), + "label": phase_a_check_labels.get(str(payload.get("key") or ""), str(payload.get("key") or "")), + "reason": str(payload.get("reason") or ""), + "actual": payload.get("actual"), + "threshold": payload.get("threshold"), + "evaluated_world_ids": [str(item) for item in payload.get("evaluated_world_ids", []) if str(item)], + } + ) + content_quality_failed_check_items = [] + for check in content_quality_evidence.get("checks", []) or []: + payload = dict(check or {}) + if payload.get("ok"): + continue + content_quality_failed_check_items.append( + { + "check_key": str(payload.get("key") or ""), + "label": content_quality_check_labels.get(str(payload.get("key") or ""), str(payload.get("key") or "")), + "reason": str(payload.get("reason") or ""), + "actual": payload.get("actual"), + "threshold": payload.get("threshold"), + "world_id": str(payload.get("world_id") or ""), + } + ) return { "blocker_count": len(blockers), "owners": by_owner, "severity_counts": by_severity, "items": blockers, + "phase_a_quality_gate": { + "available": bool(phase_a_gate_item), + "config_version": str(phase_a_evidence.get("config_version") or ""), + "failed_check_items": phase_a_failed_check_items, + }, + "content_quality_contract_gate": { + "available": bool(content_quality_gate_item), + "config_version": str(content_quality_evidence.get("config_version") or ""), + "failed_check_items": content_quality_failed_check_items, + "blocking_worlds": list(content_quality_evidence.get("blocking_worlds") or []), + }, } def _review_ownership_summary(self, history_payload: Dict[str, Any], status_payload: Dict[str, Any]) -> Dict[str, Any]: @@ -149,9 +228,75 @@ def _action_pack( "mode": "navigate", "reason": "inspect content release evidence chain", "priority": 0, - "prefill": {"world_version_id": selected_world_version_id}, + "prefill": { + "world_version_id": selected_world_version_id, + "account_id": next((item.get("author_id") for item in status_payload.get("versions", []) if item.get("world_version_id") == selected_world_version_id), None), + "claim_safe_band": dict(status_payload.get("author_longform_capability") or {}).get("claim_safe_band"), + "ops_release_ready_band": dict(status_payload.get("author_claim_alignment") or {}).get("ops_release_ready_band"), + "author_claim_alignment": dict(status_payload.get("author_claim_alignment") or {}).get("aligned"), + }, } ] + release_bundle = dict(status_payload.get("release_evidence_bundle") or {}) + strategy_batch_summary = dict(release_bundle.get("strategy_bundle_batch_validation_summary") or {}) + strategy_batch_history_summary = dict(release_bundle.get("strategy_bundle_batch_validation_history_summary") or {}) + if release_bundle: + actions.append( + { + "action_id": "inspect_release_evidence_bundle", + "label": "Inspect Release Evidence Bundle", + "handler": "inspect_release_evidence_bundle", + "mode": "navigate", + "reason": str(dict(release_bundle.get("combined_signoff") or {}).get("reason") or "review longform release evidence"), + "priority": 0, + "prefill": {"world_version_id": selected_world_version_id}, + } + ) + if bool(strategy_batch_summary.get("available", False) or strategy_batch_history_summary.get("available", False)): + actions.append( + { + "action_id": "inspect_strategy_bundle_batch_validation", + "label": "Inspect Strategy Bundle Batch Validation", + "handler": "inspect_strategy_bundle_batch_validation", + "mode": "navigate", + "reason": str( + strategy_batch_history_summary.get("trend_reason") + or strategy_batch_summary.get("decision_reason") + or "review cross-pack strategy bundle evidence" + ), + "priority": ( + 0 + if str(strategy_batch_history_summary.get("trend_status") or "") in {"deteriorating", "retire_watch"} + else 1 + ), + "prefill": { + "strategy_bundle_id": strategy_batch_summary.get("strategy_bundle_id") or strategy_batch_history_summary.get("strategy_bundle_id"), + "validate_strategy_bundle": False, + }, + } + ) + if ( + str(strategy_batch_history_summary.get("trend_status") or "") in {"deteriorating", "retire_watch"} + or str(strategy_batch_summary.get("decision") or "") in {"adapt", "retire"} + ): + actions.append( + { + "action_id": "inspect_cross_pack_quality", + "label": "Inspect Cross-Pack Quality", + "handler": "inspect_cross_pack_quality", + "mode": "navigate", + "reason": str( + strategy_batch_history_summary.get("trend_reason") + or strategy_batch_summary.get("decision_reason") + or "cross-pack remediation decision requires follow-up" + ), + "priority": 0, + "prefill": { + "strategy_bundle_id": strategy_batch_summary.get("strategy_bundle_id") or strategy_batch_history_summary.get("strategy_bundle_id"), + "validate_strategy_bundle": False, + }, + } + ) if selected_world_version_id and not publish_blockers.get("items") and selected_world_version_id != status_payload.get("published_version"): actions.append( { @@ -181,6 +326,43 @@ def _action_pack( } ) for item in publish_blockers.get("items", [])[:3]: + if str(item.get("key") or "") == "phase_a_quality_gate" and (publish_blockers.get("phase_a_quality_gate") or {}).get("failed_check_items"): + for failed_check in list((publish_blockers.get("phase_a_quality_gate") or {}).get("failed_check_items") or [])[:3]: + actions.append( + { + "action_id": f"inspect_blocker::{item.get('key')}::{failed_check.get('check_key')}", + "label": f"Inspect {failed_check.get('label') or failed_check.get('check_key')}", + "handler": "inspect_publish_blocker", + "mode": "navigate", + "reason": failed_check.get("reason") or item.get("reason") or "publish blocker present", + "priority": 1, + "prefill": { + "blocker_key": item.get("key"), + "check_key": failed_check.get("check_key"), + "world_version_id": selected_world_version_id, + }, + } + ) + continue + if str(item.get("key") or "") == "content_quality_contract_gate" and (publish_blockers.get("content_quality_contract_gate") or {}).get("failed_check_items"): + for failed_check in list((publish_blockers.get("content_quality_contract_gate") or {}).get("failed_check_items") or [])[:3]: + actions.append( + { + "action_id": f"inspect_blocker::{item.get('key')}::{failed_check.get('check_key')}::{failed_check.get('world_id')}", + "label": f"Inspect {failed_check.get('label') or failed_check.get('check_key')}", + "handler": "inspect_publish_blocker", + "mode": "navigate", + "reason": failed_check.get("reason") or item.get("reason") or "publish blocker present", + "priority": 1, + "prefill": { + "blocker_key": item.get("key"), + "check_key": failed_check.get("check_key"), + "world_id": failed_check.get("world_id"), + "world_version_id": selected_world_version_id, + }, + } + ) + continue actions.append( { "action_id": f"inspect_blocker::{item.get('key')}", @@ -236,11 +418,29 @@ def world_release_workspace(self, *, world_id: str, limit: int = 12) -> Dict[str history_payload = self.review.world_history(world_id) selected_world_version_id = self._selected_world_version_id(status_payload) publish_blockers = self._publish_blockers(status_payload) + quality_projection = self.quality_projection.quality_summary( + world_version_id=selected_world_version_id, + limit=12, + ) if selected_world_version_id else {"summary": {}, "events": [], "review_cases": []} investigation = ( self.traceability.investigate_world_version(selected_world_version_id, limit=min(limit, 12)) if selected_world_version_id else None ) + release_evidence_bundle = dict(status_payload.get("release_evidence_bundle") or {}) + combined_signoff = dict(release_evidence_bundle.get("combined_signoff") or {}) + strategy_bundle_batch_validation_summary = dict( + release_evidence_bundle.get("strategy_bundle_batch_validation_summary") or {} + ) + strategy_bundle_batch_validation_history_summary = dict( + release_evidence_bundle.get("strategy_bundle_batch_validation_history_summary") or {} + ) + reader_storybook_title_homogenization_history_summary = dict( + release_evidence_bundle.get("reader_storybook_title_homogenization_history_summary") or {} + ) + reader_storybook_title_homogenization_promoted_pairs = list( + release_evidence_bundle.get("reader_storybook_title_homogenization_promoted_pairs") or [] + ) return { "generated_at": self._utcnow(), "world_id": world_id, @@ -256,10 +456,44 @@ def world_release_workspace(self, *, world_id: str, limit: int = 12) -> Dict[str "selected_world_version_id": selected_world_version_id, "publish_ready": bool((status_payload.get("publish_checklist_summary") or {}).get("publish_ready")), "blocked_checklist_count": int((status_payload.get("publish_checklist_summary") or {}).get("blocked_count") or 0), + "release_evidence_status": combined_signoff.get("status"), + "release_evidence_ready": bool(combined_signoff.get("ready", False)), + "author_entry_mode": dict(status_payload.get("author_longform_capability") or {}).get("entry_mode"), + "author_claim_safe_band": dict(status_payload.get("author_longform_capability") or {}).get("claim_safe_band"), + "author_requested_target_band": dict(status_payload.get("author_longform_capability") or {}).get("requested_target_band"), + "ops_release_ready_band": dict(status_payload.get("author_claim_alignment") or {}).get("ops_release_ready_band"), + "author_claim_alignment": bool(dict(status_payload.get("author_claim_alignment") or {}).get("aligned")), + "strategy_bundle_batch_validation_status": self._strategy_bundle_batch_validation_status(release_evidence_bundle), + "strategy_bundle_batch_validation_reason": str( + strategy_bundle_batch_validation_summary.get("decision_reason") or "" + ), + "strategy_bundle_batch_validation_trend_status": str( + strategy_bundle_batch_validation_history_summary.get("trend_status") or "" + ), + "strategy_bundle_batch_validation_trend_reason": str( + strategy_bundle_batch_validation_history_summary.get("trend_reason") or "" + ), + "strategy_bundle_batch_validation_retire_recommended": bool( + strategy_bundle_batch_validation_history_summary.get("retire_recommended", False) + ), + "reader_storybook_title_homogenization_trend_status": str( + reader_storybook_title_homogenization_history_summary.get("trend_status") or "" + ), + "reader_storybook_title_homogenization_trend_reason": str( + reader_storybook_title_homogenization_history_summary.get("trend_reason") or "" + ), + "reader_storybook_title_homogenization_promoted_pair_count": len( + reader_storybook_title_homogenization_promoted_pairs + ), "recent_rollback_count": int((history_payload.get("rollback_summary") or {}).get("total_entries") or 0), "latest_rollback_target": (history_payload.get("rollback_summary") or {}).get("latest_target_world_version_id"), + "quality_open_case_count": int((quality_projection.get("summary") or {}).get("open_review_case_count") or 0), + "quality_blocked_event_count": int((quality_projection.get("summary") or {}).get("blocked_event_count") or 0), + "quality_review_required_event_count": int((quality_projection.get("summary") or {}).get("review_required_event_count") or 0), + "quality_latest_trace_id": (quality_projection.get("summary") or {}).get("latest_trace_id"), }, "publish_blockers": publish_blockers, + "release_evidence_bundle": release_evidence_bundle, "review_ownership_summary": self._review_ownership_summary(history_payload, status_payload), "version_matrix": self._version_matrix(history_payload), "rollback_workspace": self._rollback_workspace(status_payload, history_payload), @@ -272,14 +506,21 @@ def world_release_workspace(self, *, world_id: str, limit: int = 12) -> Dict[str "investigation_summary": { "recommended_paths": (investigation or {}).get("recommended_paths") or [], "summary": (investigation or {}).get("investigation_summary") or {}, - "export_refs": (investigation or {}).get("export_refs") or {"world_version_id": selected_world_version_id}, + "export_refs": (investigation or {}).get("export_refs") or { + "world_version_id": selected_world_version_id, + "claim_safe_band": dict(status_payload.get("author_longform_capability") or {}).get("claim_safe_band"), + "ops_release_ready_band": dict(status_payload.get("author_claim_alignment") or {}).get("ops_release_ready_band"), + "author_claim_alignment": dict(status_payload.get("author_claim_alignment") or {}).get("aligned"), + }, }, "linked_context": { "world_id": world_id, "published_version": status_payload.get("published_version"), "rollback_target_ids": [item.get("world_version_id") for item in status_payload.get("rollback_targets", [])], "review_ids": [item.get("review_id") for item in history_payload.get("review_timeline", []) if item.get("review_id")], + "quality_trace_ids": [item.get("trace_id") for item in list(quality_projection.get("events") or []) if item.get("trace_id")][:3], }, + "quality_projection_summary": quality_projection, "operator_timeline": self._operator_timeline( history_payload=history_payload, status_payload=status_payload, diff --git a/src/narrativeos/services/ops_review_hub.py b/src/narrativeos/services/ops_review_hub.py new file mode 100644 index 0000000..e010b0d --- /dev/null +++ b/src/narrativeos/services/ops_review_hub.py @@ -0,0 +1,1220 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Tuple + +from ..persistence.repositories import SQLAlchemyPlatformRepository +from .billing import BillingService +from .commercial_support import CommercialSupportService +from .customer_campaigns import CustomerCampaignService +from .governance import GovernanceService +from .ops_alerting import OpsAlertingService +from .ops_quality_projection import OpsQualityProjectionService +from .review import ReviewService + + +VALID_REVIEW_ITEM_STATUSES = { + "new", + "triaged", + "in_review", + "needs_changes", + "approved", + "blocked", + "resolved", + "dismissed", +} + +VALID_REVIEW_ITEM_DECISIONS = { + "approve": "approved", + "needs_changes": "needs_changes", + "block": "blocked", + "resolve": "resolved", + "dismiss": "dismissed", +} + +SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} +QUEUE_ORDER = {"content_release": 0, "governance": 1, "runtime": 2, "support": 3} + + +class OpsReviewHubService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + review_service: ReviewService, + governance_service: GovernanceService, + alerting_service: OpsAlertingService, + billing_service: BillingService, + commercial_support_service: CommercialSupportService, + quality_projection_service: OpsQualityProjectionService, + customer_campaign_service: CustomerCampaignService, + ) -> None: + self.repository = repository + self.review = review_service + self.governance = governance_service + self.alerting = alerting_service + self.billing = billing_service + self.commercial_support = commercial_support_service + self.quality_projection = quality_projection_service + self.customer_campaigns = customer_campaign_service + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _parse_timestamp(self, value: Optional[str]) -> datetime: + if not value: + return datetime.fromtimestamp(0, tz=timezone.utc) + 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) + + def _serialize_timestamp(self, value: Any) -> Optional[str]: + if not value: + return None + if isinstance(value, datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc).isoformat() + return str(value) + + def _severity_rank(self, severity: Optional[str]) -> int: + return SEVERITY_RANK.get(str(severity or "info"), 5) + + def _queue_rank(self, queue: Optional[str]) -> int: + return QUEUE_ORDER.get(str(queue or ""), 99) + + def _priority_for_item(self, *, queue: str, severity: str, due_at: Optional[str], source_status: str) -> int: + priority = self._queue_rank(queue) * 100 + self._severity_rank(severity) * 10 + if source_status == "blocked": + priority -= 5 + due_dt = self._parse_timestamp(due_at) + now = datetime.now(timezone.utc) + if due_at and due_dt <= now: + priority -= 8 + elif due_at and due_dt <= now + timedelta(hours=24): + priority -= 4 + return max(0, priority) + + def _sla_bucket(self, *, due_at: Optional[str], status: str) -> str: + if status in {"resolved", "dismissed"}: + return "closed" + if not due_at: + return "backlog" + due_dt = self._parse_timestamp(due_at) + now = datetime.now(timezone.utc) + if due_dt <= now: + return "overdue" + if due_dt <= now + timedelta(hours=24): + return "due_soon" + return "scheduled" + + def _source_key(self, item: Dict[str, Any]) -> Tuple[str, str]: + return str(item.get("source_type") or ""), str(item.get("source_id") or "") + + def _link(self, *, kind: str, entity_id: Optional[str], label: Optional[str] = None) -> Optional[Dict[str, Any]]: + value = str(entity_id or "").strip() + if not value: + return None + return { + "kind": kind, + "id": value, + "label": str(label or value), + } + + def _merge_overlay(self, *, source_item: Dict[str, Any], overlay: Optional[Dict[str, Any]]) -> Dict[str, Any]: + if not overlay: + return source_item + merged = dict(source_item) + source_status = str(source_item.get("status") or "") + if source_status not in {"resolved", "dismissed"}: + merged["status"] = overlay.get("status") or source_status + merged["owner_id"] = overlay.get("owner_id") or source_item.get("owner_id") + merged["reviewer_id"] = overlay.get("reviewer_id") or source_item.get("reviewer_id") + merged["due_at"] = overlay.get("due_at") or source_item.get("due_at") + merged["priority"] = int(overlay.get("priority", source_item.get("priority", 100)) or source_item.get("priority", 100)) + return merged + + def _support_candidate_account_ids(self) -> List[str]: + account_ids = { + str(item.get("account_id") or "").strip() + for item in self.repository.list_subscriptions() + if str(item.get("account_id") or "").strip() + } + account_ids.update( + str(item.get("author_id") or "").strip() + for item in self.repository.list_world_versions(status="draft") + if str(item.get("author_id") or "").strip() + ) + account_ids.update( + str(item.get("author_id") or "").strip() + for item in self.repository.list_world_versions(status="submitted") + if str(item.get("author_id") or "").strip() + ) + return sorted(account_ids) + + def _author_work_status_to_item_status(self, work_status: str) -> str: + return { + "draft": "new", + "review_ready": "triaged", + "submitted": "in_review", + "approved": "approved", + "needs_changes": "needs_changes", + }.get(work_status, "new") + + def _build_author_work_items(self) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + works = self.repository.list_author_works(limit=500) + for work in works: + chapters = self.repository.list_author_work_chapters(work_id=work["work_id"]) + diagnostics = dict(work.get("diagnostics_summary_json") or {}) + has_reviewable_content = bool(chapters) and bool(diagnostics) + if work.get("status") not in {"review_ready", "submitted", "approved", "needs_changes"} and not has_reviewable_content: + continue + try: + version = self.repository.get_world_version(str(work.get("world_version_id") or "")) + world_id = version.world_id + except KeyError: + world_id = None + evaluation_summary = dict(diagnostics.get("evaluation_summary") or {}) + latest_decision = diagnostics.get("latest_decision") + item_status = self._author_work_status_to_item_status(str(work.get("status") or "draft")) + if float(evaluation_summary.get("block_rate", 0.0) or 0.0) > 0.0: + item_status = "blocked" + severity = ( + "high" + if item_status in {"blocked", "needs_changes"} + else ("medium" if item_status == "in_review" else "low") + ) + due_at = (self._parse_timestamp(work.get("updated_at")) + timedelta(hours=24)).isoformat() if work.get("updated_at") else None + item = { + "review_item_id": f"ops_review::author_work::{work['work_id']}", + "source_type": "author_work", + "source_id": str(work["work_id"]), + "queue": "content_release", + "status": item_status, + "severity": severity, + "priority": self._priority_for_item(queue="content_release", severity=severity, due_at=due_at, source_status=item_status), + "owner_id": None, + "reviewer_id": None, + "account_id": work.get("account_id"), + "world_id": world_id, + "world_version_id": work.get("world_version_id"), + "headline": f"{work.get('title') or work['work_id']} · 作品稿审阅", + "summary": f"chapters {work.get('chapter_count', 0)}/{work.get('target_chapter_count', 0)} · decision {latest_decision or '-'}", + "recommended_action": "inspect_author_work" if item_status in {"in_review", "blocked", "needs_changes"} else "open_release_workspace", + "due_at": due_at, + "sla_bucket": self._sla_bucket(due_at=due_at, status=item_status), + "allowed_actions": ["assign_to_me", "mark_triaged", "mark_in_review", "approve", "needs_changes", "block", "open_account_workspace", "open_release_workspace"], + "linked_entities": [ + item + for item in [ + self._link(kind="account", entity_id=work.get("account_id")), + self._link(kind="world_version", entity_id=work.get("world_version_id")), + self._link(kind="author_work", entity_id=work.get("work_id")), + ] + if item + ], + "source_updated_at": work.get("updated_at"), + "source_payload": { + "work": work, + "chapters": [ + { + "chapter_index": chapter.get("chapter_index"), + "chapter_title": chapter.get("chapter_title"), + "status": chapter.get("status"), + "source_type": chapter.get("source_type"), + "summary": chapter.get("summary"), + } + for chapter in chapters + ], + "diagnostics": diagnostics, + }, + } + items.append(item) + return items + + def _build_release_items(self, *, covered_world_version_ids: Optional[set[str]] = None) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + for review_item in self.review.queue(): + note_payload = review_item.get("notes") + latest_decision = review_item.get("latest_decision") + gate_errors = list(review_item.get("publish_gate_errors") or []) + status = "new" + if gate_errors or latest_decision == "block": + status = "blocked" + elif latest_decision == "rewrite": + status = "needs_changes" + world_version_id = str(review_item.get("asset_id") or "") + if covered_world_version_ids and world_version_id in covered_world_version_ids: + continue + try: + version = self.repository.get_world_version(world_version_id) + account_id = version.author_id + world_id = version.world_id + title = (version.worldpack_json or {}).get("title") or world_version_id + except KeyError: + account_id = None + world_id = None + title = world_version_id + severity = "high" if gate_errors else ("medium" if latest_decision != "pass" else "low") + due_at = (self._parse_timestamp(review_item.get("updated_at")) + timedelta(hours=24)).isoformat() if review_item.get("updated_at") else None + item = { + "review_item_id": f"ops_review::world_version_review::{world_version_id}", + "source_type": "world_version_review", + "source_id": world_version_id, + "queue": "content_release", + "status": status, + "severity": severity, + "priority": self._priority_for_item( + queue="content_release", + severity=severity, + due_at=due_at, + source_status=status, + ), + "owner_id": None, + "reviewer_id": review_item.get("reviewer_id"), + "account_id": account_id, + "world_id": world_id, + "world_version_id": world_version_id, + "headline": f"{title} · 发布审阅", + "summary": f"decision {latest_decision or '-'} · gate {(gate_errors or ['none'])[0]}", + "recommended_action": "inspect_publish_blockers" if gate_errors else ("review_candidate" if latest_decision != "pass" else "publish_candidate"), + "due_at": due_at, + "sla_bucket": self._sla_bucket(due_at=due_at, status=status), + "allowed_actions": ["assign_to_me", "mark_triaged", "mark_in_review", "approve", "needs_changes", "block", "open_release_workspace", "open_account_workspace"], + "linked_entities": [ + item + for item in [ + self._link(kind="account", entity_id=account_id), + self._link(kind="world", entity_id=world_id), + self._link(kind="world_version", entity_id=world_version_id), + ] + if item + ], + "source_updated_at": review_item.get("updated_at"), + "source_payload": { + "review_record": review_item, + "publish_gate_errors": gate_errors, + "top_failing_packs": review_item.get("top_failing_packs", []), + }, + } + items.append(item) + return items + + def _build_governance_items(self) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + cases = list((self.governance.list_cases(limit=200) or {}).get("cases") or []) + for case in cases: + source_status = { + "open": "new", + "in_review": "in_review", + "escalated": "in_review", + "resolved": "resolved", + "dismissed": "dismissed", + }.get(str(case.get("status") or "open"), "new") + owner_id = (case.get("workflow_summary") or {}).get("owner_id") or case.get("owner_id") + due_at = case.get("due_at") or (case.get("workflow_summary") or {}).get("due_at") + severity = str(case.get("severity") or "medium") + item = { + "review_item_id": f"ops_review::governance_case::{case['case_id']}", + "source_type": "governance_case", + "source_id": str(case["case_id"]), + "queue": "governance", + "status": source_status, + "severity": severity, + "priority": self._priority_for_item(queue="governance", severity=severity, due_at=due_at, source_status=source_status), + "owner_id": owner_id, + "reviewer_id": case.get("reviewer_id"), + "account_id": case.get("account_id"), + "world_id": case.get("world_id"), + "world_version_id": case.get("world_version_id"), + "headline": str(case.get("summary") or case.get("case_id") or "governance_case"), + "summary": str(case.get("description") or case.get("resolution_notes") or case.get("case_type") or ""), + "recommended_action": ((case.get("recommended_next_actions") or [])[:1] or [case.get("recommended_action") or "review_governance_case"])[0], + "due_at": due_at, + "sla_bucket": self._sla_bucket(due_at=due_at, status=source_status), + "allowed_actions": ["assign_to_me", "mark_in_review", "resolve", "dismiss", "open_governance_case", "open_account_workspace", "open_investigation"], + "linked_entities": [ + item + for item in [ + self._link(kind="account", entity_id=case.get("account_id")), + self._link(kind="world", entity_id=case.get("world_id")), + self._link(kind="world_version", entity_id=case.get("world_version_id")), + self._link(kind="case", entity_id=case.get("case_id")), + ] + if item + ], + "source_updated_at": case.get("updated_at"), + "source_payload": case, + } + items.append(item) + + restriction = dict(case.get("restriction") or {}) + if restriction: + restriction_status = { + "active": "blocked", + "released": "resolved", + }.get(str(restriction.get("status") or "active"), "blocked") + applied_at = restriction.get("applied_at") or restriction.get("released_at") + restriction_due_at = restriction.get("expires_at") + restriction_item = { + "review_item_id": f"ops_review::governance_restriction::{restriction.get('restriction_id') or case['case_id']}", + "source_type": "governance_restriction", + "source_id": str(restriction.get("restriction_id") or case["case_id"]), + "queue": "governance", + "status": restriction_status, + "severity": severity, + "priority": self._priority_for_item(queue="governance", severity=severity, due_at=restriction_due_at, source_status=restriction_status), + "owner_id": owner_id, + "reviewer_id": case.get("reviewer_id"), + "account_id": case.get("account_id"), + "world_id": case.get("world_id"), + "world_version_id": case.get("world_version_id"), + "headline": f"{restriction.get('restriction_type') or 'restriction'} · {case.get('summary') or case.get('case_id')}", + "summary": str(restriction.get("restriction_reason") or case.get("summary") or ""), + "recommended_action": "release_restriction" if restriction_status == "blocked" else "review_governance_case", + "due_at": restriction_due_at, + "sla_bucket": self._sla_bucket(due_at=restriction_due_at, status=restriction_status), + "allowed_actions": ["open_governance_case", "open_account_workspace"] + (["resolve"] if restriction_status == "blocked" else []), + "linked_entities": [ + item + for item in [ + self._link(kind="account", entity_id=case.get("account_id")), + self._link(kind="world", entity_id=case.get("world_id")), + self._link(kind="world_version", entity_id=case.get("world_version_id")), + self._link(kind="case", entity_id=case.get("case_id")), + self._link(kind="restriction", entity_id=restriction.get("restriction_id") or case.get("case_id")), + ] + if item + ], + "source_updated_at": applied_at, + "source_payload": { + "case": case, + "restriction": restriction, + }, + } + items.append(restriction_item) + return items + + def _queue_for_alert(self, alert: Dict[str, Any]) -> str: + category = str(alert.get("category") or "") + if category == "governance": + return "governance" + if category == "support": + return "support" + return "runtime" + + def _build_alert_items(self) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + for alert in list((self.alerting.list_alerts(status_filter="all", limit=200) or {}).get("alerts") or []): + queue = self._queue_for_alert(alert) + alert_status = str(alert.get("status") or "open") + source_status = { + "resolved": "resolved", + "suppressed": "dismissed", + "acknowledged": "triaged", + "open": "new", + }.get(alert_status, "new") + severity = str(alert.get("severity") or "medium") + investigation_ref = dict(alert.get("investigation_ref") or {}) + due_at = (self._parse_timestamp(alert.get("detected_at")) + timedelta(hours=12)).isoformat() if alert.get("detected_at") else None + item = { + "review_item_id": f"ops_review::active_alert::{alert['alert_id']}", + "source_type": "active_alert", + "source_id": str(alert["alert_id"]), + "queue": queue, + "status": source_status, + "severity": severity, + "priority": self._priority_for_item(queue=queue, severity=severity, due_at=due_at, source_status=source_status), + "owner_id": None, + "reviewer_id": None, + "account_id": alert.get("account_id") or investigation_ref.get("account_id"), + "world_id": investigation_ref.get("world_id"), + "world_version_id": investigation_ref.get("world_version_id"), + "headline": str(alert.get("title") or alert.get("alert_id") or "alert"), + "summary": str(alert.get("summary") or ""), + "recommended_action": ((alert.get("recommended_actions") or [])[:1] or ["open_investigation"])[0], + "due_at": due_at, + "sla_bucket": self._sla_bucket(due_at=due_at, status=source_status), + "allowed_actions": ["assign_to_me", "mark_triaged", "mark_in_review", "resolve", "dismiss", "open_investigation", "open_account_workspace"], + "linked_entities": [ + item + for item in [ + self._link(kind="account", entity_id=alert.get("account_id") or investigation_ref.get("account_id")), + self._link(kind="world_version", entity_id=investigation_ref.get("world_version_id")), + self._link(kind="case", entity_id=investigation_ref.get("case_id")), + self._link(kind="alert", entity_id=alert.get("alert_id")), + ] + if item + ], + "source_updated_at": alert.get("detected_at"), + "source_payload": alert, + } + items.append(item) + return items + + def _build_support_items(self) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + for account_id in self._support_candidate_account_ids(): + lookup = self.billing.support_issue_lookup(account_id=account_id, limit=20) + for issue in list(lookup.get("support_issues") or []): + severity = str(issue.get("severity") or "medium") + detected_at = issue.get("detected_at") + due_at = (self._parse_timestamp(detected_at) + timedelta(hours=24)).isoformat() if detected_at else None + related_objects = dict(issue.get("related_objects") or {}) + item = { + "review_item_id": f"ops_review::support_issue::{issue['issue_id']}", + "source_type": "support_issue", + "source_id": str(issue["issue_id"]), + "queue": "support", + "status": "new", + "severity": severity, + "priority": self._priority_for_item(queue="support", severity=severity, due_at=due_at, source_status="new"), + "owner_id": None, + "reviewer_id": None, + "account_id": account_id, + "world_id": (related_objects.get("world_ids") or [None])[0], + "world_version_id": (related_objects.get("world_version_ids") or [None])[0], + "headline": str(issue.get("title") or issue.get("issue_id") or "support_issue"), + "summary": str(issue.get("summary") or issue.get("reason") or ""), + "recommended_action": (((issue.get("suggested_operator_actions") or [])[:1] or [{"action_type": "open_account_workspace"}])[0]).get("action_type"), + "due_at": due_at, + "sla_bucket": self._sla_bucket(due_at=due_at, status="new"), + "allowed_actions": ["assign_to_me", "mark_triaged", "mark_in_review", "resolve", "dismiss", "open_account_workspace", "open_investigation", "escalate_to_governance"], + "linked_entities": [ + item + for item in [ + self._link(kind="account", entity_id=account_id), + self._link(kind="world_version", entity_id=(related_objects.get("world_version_ids") or [None])[0]), + self._link(kind="session", entity_id=(related_objects.get("session_ids") or [None])[0]), + self._link(kind="support_issue", entity_id=issue.get("issue_id")), + ] + if item + ], + "source_updated_at": detected_at, + "source_payload": issue, + } + items.append(item) + return items + + def _build_quality_review_case_items(self) -> List[Dict[str, Any]]: + return self.quality_projection.build_quality_review_case_items() + + def _build_campaign_review_items(self) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + for submission in self.repository.list_campaign_review_submissions(limit=500): + if str(submission.get("status") or "") not in {"submitted", "in_review"}: + continue + try: + bundle = self.customer_campaigns.campaign_detail( + account_id=self.repository.get_campaign(str(submission.get("campaign_id") or ""))["account_id"], + campaign_id=str(submission.get("campaign_id") or ""), + ) + except Exception: + continue + campaign = dict(bundle.get("campaign") or {}) + review_case = dict(bundle.get("review_case") or {}) + status = "in_review" if str(submission.get("status") or "") == "in_review" else "new" + due_at = (self._parse_timestamp(submission.get("submitted_at")) + timedelta(hours=24)).isoformat() if submission.get("submitted_at") else None + item = { + "review_item_id": f"ops_review::campaign_review_case::{submission['submission_id']}", + "source_type": "campaign_review_case", + "source_id": str(submission["submission_id"]), + "queue": "content_release", + "status": status, + "severity": "medium", + "priority": self._priority_for_item(queue="content_release", severity="medium", due_at=due_at, source_status=status), + "owner_id": review_case.get("owner_id"), + "reviewer_id": submission.get("reviewer_id"), + "account_id": campaign.get("account_id"), + "world_id": None, + "world_version_id": None, + "headline": f"{campaign.get('title') or campaign.get('campaign_id')} · Campaign Activation", + "summary": f"ICP {campaign.get('target_icp_vertical') or '-'} · channels {' / '.join(campaign.get('selected_channels_json') or []) or '-'}", + "recommended_action": "review_campaign_activation", + "due_at": due_at, + "sla_bucket": self._sla_bucket(due_at=due_at, status=status), + "allowed_actions": ["assign_to_me", "mark_in_review", "approve", "needs_changes", "block", "open_account_workspace", "open_investigation"], + "linked_entities": [ + item + for item in [ + self._link(kind="account", entity_id=campaign.get("account_id")), + self._link(kind="campaign", entity_id=campaign.get("campaign_id")), + self._link(kind="review_case", entity_id=review_case.get("case_id")), + ] + if item + ], + "source_updated_at": submission.get("updated_at"), + "source_payload": { + "campaign": campaign, + "proof_bundles": bundle.get("proof_bundles") or [], + "channel_targets": bundle.get("channel_targets") or [], + "submission": submission, + "review_case": review_case, + }, + } + items.append(item) + return items + + def _review_item_fallback_chapter_index(self, payload: Dict[str, Any], fallback: int) -> int: + for key in ("chapter_index", "turn_index"): + value = payload.get(key) + try: + if value is not None: + return int(value) + except (TypeError, ValueError): + continue + chapter_id = str(payload.get("chapter_id") or "") + if chapter_id: + suffix = chapter_id.rsplit("_", 1)[-1] + try: + return int(suffix) + except ValueError: + pass + return fallback + + def _synthetic_work_chapters_from_simulation(self, simulation_report: Dict[str, Any]) -> List[Dict[str, Any]]: + chapter_trace = list(simulation_report.get("chapter_trace") or []) + chapter_evaluations = list(simulation_report.get("chapter_evaluations") or []) + evaluation_by_index: Dict[int, Dict[str, Any]] = {} + for fallback_index, evaluation in enumerate(chapter_evaluations, start=1): + payload = dict(evaluation or {}) + chapter_index = self._review_item_fallback_chapter_index(payload, fallback_index) + evaluation_by_index[chapter_index] = payload + + chapters: List[Dict[str, Any]] = [] + for fallback_index, trace in enumerate(chapter_trace, start=1): + payload = dict(trace or {}) + chapter_index = self._review_item_fallback_chapter_index(payload, fallback_index) + evaluation = evaluation_by_index.get(chapter_index, {}) + trace_evaluation = dict(payload.get("evaluation") or {}) + issue_codes = list(trace_evaluation.get("issue_codes") or []) + if not issue_codes: + issue_codes = [ + str(issue.get("issue_code") or "") + for issue in list(evaluation.get("issues") or []) + if str(issue.get("issue_code") or "").strip() + ] + decision = ( + str(trace_evaluation.get("decision") or "") + or str(dict(evaluation.get("decision") or {}).get("decision") or "") + or str(simulation_report.get("latest_decision") or "rewrite") + ) + overall_score = trace_evaluation.get("overall_score") + if overall_score is None: + overall_score = dict(evaluation.get("scores") or {}).get("overall_score") + chapters.append( + { + "chapter_index": chapter_index, + "chapter_title": str(payload.get("chapter_title") or f"第 {chapter_index} 章"), + "status": decision or "rewrite", + "source_type": "world_version_review", + "summary": str(payload.get("chosen_event_title") or payload.get("scene_function") or ""), + "body": str(payload.get("body_excerpt") or ""), + "choices": list(payload.get("choices_preview") or []), + "chapter_task_json": dict(payload.get("chapter_task") or {}), + "diagnostic_summary_json": { + "decision": {"decision": decision or "rewrite"}, + "scores": {"overall_score": overall_score}, + "issues": [{"issue_code": code} for code in issue_codes], + }, + "latest_diagnostic_summary": { + "decision": decision or "rewrite", + "overall_score": overall_score, + "issue_codes": issue_codes, + }, + } + ) + return chapters + + def _synthetic_review_work_from_world_version(self, *, item: Dict[str, Any]) -> Dict[str, Any]: + world_version_id = str(item.get("world_version_id") or item.get("source_id") or "") + version = self.repository.get_world_version(world_version_id) + worldpack = dict(version.worldpack_json or {}) + manifest = dict(worldpack.get("manifest") or {}) + simulation_report = dict(version.simulation_report_json or {}) + validation_report = dict(version.validation_report_json or {}) + chapters = self._synthetic_work_chapters_from_simulation(simulation_report) + completed_chapters = int(simulation_report.get("completed_chapters", 0) or 0) + chapter_budget = int(simulation_report.get("chapter_budget", 0) or 0) + latest_revision = { + "revision_id": f"world_version_review::{world_version_id}", + "revision_type": "submitted_world_version", + "created_at": self._serialize_timestamp(getattr(version, "updated_at", None)), + "snapshot_json": { + "validation_report": validation_report, + "simulation_report": simulation_report, + }, + } + return { + "work_id": f"world_version_review::{world_version_id}", + "root_work_id": None, + "branch_id": None, + "is_active_line": False, + "account_id": getattr(version, "author_id", None), + "world_version_id": world_version_id, + "title": str(worldpack.get("title") or world_version_id), + "status": str(getattr(version, "status", None) or item.get("status") or "submitted"), + "chapter_count": max(completed_chapters, len(chapters)), + "target_chapter_count": max(chapter_budget, completed_chapters, len(chapters)), + "active_chapter_index": chapters[-1]["chapter_index"] if chapters else None, + "chapters": chapters, + "revisions": [latest_revision], + "latest_revision": latest_revision, + "diagnostics_summary_json": simulation_report, + "diagnostics_summary": simulation_report, + "validation_report_json": validation_report, + "hard_constraint_status": "clear", + "blocking_dimension": "", + "window_breach_kind": "", + "ready_for_validation": True, + "content_quality_repair_workbench": dict(simulation_report.get("content_quality_repair_workbench") or {}), + "branch_family": [], + "source_mode": "world_version_review_fallback", + "source_summary": { + "world_id": getattr(version, "world_id", None), + "review_item_id": item.get("review_item_id"), + "author_id": getattr(version, "author_id", None), + "latest_decision": simulation_report.get("latest_decision"), + "stop_reason": simulation_report.get("stop_reason"), + "publish_gate_errors": list((dict(item.get("source_payload") or {}).get("publish_gate_errors") or [])), + "manifest_author_id": manifest.get("author_id"), + }, + } + + def _build_dispute_items(self) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + for dispute in self.repository.list_disputes(limit=500): + source_status = { + "open": "new", + "under_review": "in_review", + "approved": "in_review", + "credited": "resolved", + "reversed": "resolved", + "refunded": "resolved", + "resolved": "resolved", + "rejected": "dismissed", + }.get(str(dispute.get("status") or "open"), "new") + due_at = (self._parse_timestamp(dispute.get("created_at")) + timedelta(hours=24)).isoformat() if dispute.get("created_at") else None + item = { + "review_item_id": f"ops_review::dispute::{dispute['dispute_id']}", + "source_type": "dispute", + "source_id": str(dispute["dispute_id"]), + "queue": "support", + "status": source_status, + "severity": "high", + "priority": self._priority_for_item(queue="support", severity="high", due_at=due_at, source_status=source_status), + "owner_id": dispute.get("reviewer_id"), + "reviewer_id": dispute.get("reviewer_id"), + "account_id": dispute.get("account_id"), + "world_id": None, + "world_version_id": None, + "headline": f"Dispute · {dispute.get('dispute_reason_code') or dispute.get('dispute_id')}", + "summary": f"amount {dispute.get('requested_amount_usd') or 0} · billable_event {dispute.get('billable_event_id') or '-'}", + "recommended_action": "review_dispute", + "due_at": due_at, + "sla_bucket": self._sla_bucket(due_at=due_at, status=source_status), + "allowed_actions": ["assign_to_me", "mark_in_review", "resolve", "dismiss", "open_account_workspace", "open_investigation"], + "linked_entities": [ + item + for item in [ + self._link(kind="account", entity_id=dispute.get("account_id")), + self._link(kind="campaign", entity_id=dispute.get("campaign_id")), + self._link(kind="billable_event", entity_id=dispute.get("billable_event_id")), + self._link(kind="trace", entity_id=dispute.get("trace_id")), + ] + if item + ], + "source_updated_at": dispute.get("updated_at"), + "source_payload": {"dispute": dispute}, + } + items.append(item) + return items + + def _build_support_case_items(self) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + for case in self.repository.list_support_cases(limit=500): + source_status = { + "open": "new", + "in_progress": "in_review", + "resolved": "resolved", + "dismissed": "dismissed", + }.get(str(case.get("status") or "open"), "new") + severity = "high" if str(case.get("priority") or "") == "high" else "medium" + due_at = (self._parse_timestamp(case.get("created_at")) + timedelta(hours=24)).isoformat() if case.get("created_at") else None + item = { + "review_item_id": f"ops_review::support_case::{case['support_case_id']}", + "source_type": "support_case", + "source_id": str(case["support_case_id"]), + "queue": "support", + "status": source_status, + "severity": severity, + "priority": self._priority_for_item(queue="support", severity=severity, due_at=due_at, source_status=source_status), + "owner_id": case.get("owner_id"), + "reviewer_id": case.get("owner_id"), + "account_id": case.get("account_id"), + "world_id": None, + "world_version_id": None, + "headline": f"{case.get('subject') or case.get('support_case_id')} · Support", + "summary": f"type {case.get('case_type') or '-'} · priority {case.get('priority') or '-'}", + "recommended_action": "review_support_case", + "due_at": due_at, + "sla_bucket": self._sla_bucket(due_at=due_at, status=source_status), + "allowed_actions": ["assign_to_me", "mark_in_review", "resolve", "dismiss", "open_account_workspace", "open_investigation"], + "linked_entities": [ + item + for item in [ + self._link(kind="account", entity_id=case.get("account_id")), + self._link(kind="campaign", entity_id=case.get("campaign_id")), + self._link(kind="billable_event", entity_id=case.get("billable_event_id")), + self._link(kind="trace", entity_id=case.get("trace_id")), + ] + if item + ], + "source_updated_at": case.get("updated_at"), + "source_payload": {"support_case": case}, + } + items.append(item) + return items + + def _synchronize_items(self) -> List[Dict[str, Any]]: + existing = { + (item["source_type"], item["source_id"]): item + for item in self.repository.list_ops_review_items(limit=1000) + } + author_work_items = self._build_author_work_items() + covered_world_version_ids = { + str(item.get("world_version_id") or "") + for item in author_work_items + if str(item.get("world_version_id") or "").strip() + } + source_items = [ + *author_work_items, + *self._build_release_items(covered_world_version_ids=covered_world_version_ids), + *self._build_governance_items(), + *self._build_alert_items(), + *self._build_support_items(), + *self._build_dispute_items(), + *self._build_support_case_items(), + *self._build_quality_review_case_items(), + *self._build_campaign_review_items(), + ] + synchronized: List[Dict[str, Any]] = [] + for source_item in source_items: + overlay = existing.get(self._source_key(source_item)) + merged = self._merge_overlay(source_item=source_item, overlay=overlay) + persisted = self.repository.upsert_ops_review_item_by_source( + { + **merged, + "linked_entities": merged.get("linked_entities", []), + "allowed_actions": merged.get("allowed_actions", []), + "source_updated_at": source_item.get("source_updated_at"), + "last_synced_at": self._utcnow(), + } + ) + synchronized.append({**persisted, "source_payload": source_item.get("source_payload", {})}) + synchronized.sort( + key=lambda item: ( + int(item.get("priority", 100)), + self._parse_timestamp(item.get("updated_at")), + ), + reverse=False, + ) + return synchronized + + def _filter_items( + self, + items: List[Dict[str, Any]], + *, + queue: Optional[str] = None, + status: Optional[str] = None, + owner_id: Optional[str] = None, + severity: Optional[str] = None, + account_id: Optional[str] = None, + world_id: Optional[str] = None, + world_version_id: Optional[str] = None, + limit: int = 100, + ) -> List[Dict[str, Any]]: + filtered = list(items) + if queue is not None: + filtered = [item for item in filtered if item.get("queue") == queue] + if status is not None: + filtered = [item for item in filtered if item.get("status") == status] + if owner_id is not None: + filtered = [item for item in filtered if item.get("owner_id") == owner_id] + if severity is not None: + filtered = [item for item in filtered if item.get("severity") == severity] + if account_id is not None: + filtered = [item for item in filtered if item.get("account_id") == account_id] + if world_id is not None: + filtered = [item for item in filtered if item.get("world_id") == world_id] + if world_version_id is not None: + filtered = [item for item in filtered if item.get("world_version_id") == world_version_id] + return filtered[:limit] + + def _summary(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: + now = datetime.now(timezone.utc) + queue_counts: Dict[str, int] = {} + status_counts: Dict[str, int] = {} + severity_counts: Dict[str, int] = {} + for item in items: + queue = str(item.get("queue") or "unknown") + status = str(item.get("status") or "unknown") + severity = str(item.get("severity") or "unknown") + queue_counts[queue] = queue_counts.get(queue, 0) + 1 + status_counts[status] = status_counts.get(status, 0) + 1 + severity_counts[severity] = severity_counts.get(severity, 0) + 1 + return { + "total_count": len(items), + "queue_counts": queue_counts, + "status_counts": status_counts, + "severity_counts": severity_counts, + "unassigned_count": sum(1 for item in items if not item.get("owner_id")), + "blocked_count": sum(1 for item in items if item.get("status") == "blocked"), + "overdue_count": sum(1 for item in items if item.get("due_at") and self._parse_timestamp(item.get("due_at")) <= now and item.get("status") not in {"resolved", "dismissed"}), + "due_soon_count": sum( + 1 + for item in items + if item.get("due_at") + and now < self._parse_timestamp(item.get("due_at")) <= now + timedelta(hours=24) + and item.get("status") not in {"resolved", "dismissed"} + ), + "actionable_count": sum(1 for item in items if item.get("status") not in {"resolved", "dismissed"}), + } + + def _triage(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: + def _top(values: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + return values[:8] + + return { + "unassigned": _top([item for item in items if not item.get("owner_id") and item.get("status") not in {"resolved", "dismissed"}]), + "blocked": _top([item for item in items if item.get("status") == "blocked"]), + "due_soon": _top([item for item in items if item.get("sla_bucket") in {"due_soon", "overdue"} and item.get("status") not in {"resolved", "dismissed"}]), + "by_queue": { + queue: _top([item for item in items if item.get("queue") == queue]) + for queue in ["content_release", "governance", "runtime", "support"] + }, + } + + def review_hub( + self, + *, + queue: Optional[str] = None, + status: Optional[str] = None, + owner_id: Optional[str] = None, + severity: Optional[str] = None, + account_id: Optional[str] = None, + world_id: Optional[str] = None, + world_version_id: Optional[str] = None, + limit: int = 100, + ) -> Dict[str, Any]: + synchronized = self._synchronize_items() + filtered = self._filter_items( + synchronized, + queue=queue, + status=status, + owner_id=owner_id, + severity=severity, + account_id=account_id, + world_id=world_id, + world_version_id=world_version_id, + limit=limit, + ) + return { + "generated_at": self._utcnow(), + "filters": { + "queue": queue, + "status": status, + "owner_id": owner_id, + "severity": severity, + "account_id": account_id, + "world_id": world_id, + "world_version_id": world_version_id, + "limit": limit, + }, + "summary": self._summary(filtered), + "triage": self._triage(filtered), + "items": filtered, + } + + def review_hub_cached( + self, + *, + queue: Optional[str] = None, + status: Optional[str] = None, + owner_id: Optional[str] = None, + severity: Optional[str] = None, + account_id: Optional[str] = None, + world_id: Optional[str] = None, + world_version_id: Optional[str] = None, + limit: int = 100, + ) -> Dict[str, Any]: + items = self.repository.list_ops_review_items( + queue=queue, + status=status, + owner_id=owner_id, + severity=severity, + account_id=account_id, + world_id=world_id, + world_version_id=world_version_id, + limit=limit, + ) + return { + "generated_at": self._utcnow(), + "filters": { + "queue": queue, + "status": status, + "owner_id": owner_id, + "severity": severity, + "account_id": account_id, + "world_id": world_id, + "world_version_id": world_version_id, + "limit": limit, + "source": "cached", + }, + "summary": self._summary(items), + "triage": self._triage(items), + "items": items, + } + + def review_item_detail(self, review_item_id: str) -> Dict[str, Any]: + synchronized = {item["review_item_id"]: item for item in self._synchronize_items()} + item = synchronized.get(review_item_id) + if item is None: + persisted = self.repository.get_ops_review_item(review_item_id) + item = {**persisted, "source_payload": {}} + if item.get("source_type") in {"quality_review_case", "campaign_review_case"}: + source_payload = dict(item.get("source_payload") or {}) + if not source_payload: + if item.get("source_type") == "quality_review_case": + trace_id = None + linked_entities = list(item.get("linked_entities") or []) + for entity in linked_entities: + if str(entity.get("kind") or "") == "trace": + trace_id = entity.get("id") + break + if trace_id: + try: + trace_detail = self.quality_projection.quality_trace_detail(str(trace_id)) + except KeyError: + trace_detail = {} + source_payload = { + "review_case": self.repository.get_review_case(item["source_id"]), + "quality_event": dict(trace_detail.get("event") or {}), + "content_quality_score": dict(trace_detail.get("score") or {}), + "trace_summary": { + "trace_id": trace_id, + "linked_context": dict(trace_detail.get("linked_context") or {}), + }, + } + item = {**item, "source_payload": source_payload} + return { + "generated_at": self._utcnow(), + "review_item": item, + "detail_summary": { + "queue": item.get("queue"), + "status": item.get("status"), + "severity": item.get("severity"), + "allowed_actions": list(item.get("allowed_actions") or []), + "linked_entity_count": len(item.get("linked_entities") or []), + "source_type": item.get("source_type"), + }, + } + + def review_item_detail_cached(self, review_item_id: str) -> Dict[str, Any]: + item = {**self.repository.get_ops_review_item(review_item_id), "source_payload": {}} + return { + "generated_at": self._utcnow(), + "review_item": item, + "detail_summary": { + "queue": item.get("queue"), + "status": item.get("status"), + "severity": item.get("severity"), + "allowed_actions": list(item.get("allowed_actions") or []), + "linked_entity_count": len(item.get("linked_entities") or []), + "source_type": item.get("source_type"), + }, + } + + def review_item_work_detail(self, review_item_id: str) -> Dict[str, Any]: + detail = self.review_item_detail(review_item_id) + item = detail["review_item"] + if item.get("source_type") == "author_work": + work = self.repository.get_author_work(item["source_id"]) + chapters = self.repository.list_author_work_chapters(work_id=item["source_id"]) + revisions = self.repository.list_author_work_revisions(work_id=item["source_id"], limit=20) + return { + **detail, + "work": { + **work, + "chapters": chapters, + "revisions": revisions, + "latest_revision": revisions[0] if revisions else None, + }, + } + world_version_id = str(item.get("world_version_id") or "") + if world_version_id: + works = self.repository.list_author_works(world_version_id=world_version_id, limit=10) + if works: + work = works[0] + chapters = self.repository.list_author_work_chapters(work_id=work["work_id"]) + revisions = self.repository.list_author_work_revisions(work_id=work["work_id"], limit=20) + return { + **detail, + "work": { + **work, + "chapters": chapters, + "revisions": revisions, + "latest_revision": revisions[0] if revisions else None, + }, + } + if item.get("source_type") == "world_version_review": + return { + **detail, + "work": self._synthetic_review_work_from_world_version(item=item), + } + raise KeyError(f"review_item_work_missing:{review_item_id}") + + def assign_review_item(self, *, review_item_id: str, owner_id: str, reviewer_id: str) -> Dict[str, Any]: + if not str(owner_id or "").strip(): + raise ValueError("owner_id_required") + if not str(reviewer_id or "").strip(): + raise ValueError("reviewer_id_required") + item = self.repository.get_ops_review_item(review_item_id) + updated = self.repository.save_ops_review_item( + { + **item, + "owner_id": owner_id, + "reviewer_id": reviewer_id, + "status": item.get("status") if item.get("source_type") == "quality_review_case" else ("triaged" if item.get("status") == "new" else item.get("status")), + } + ) + if item.get("source_type") == "quality_review_case": + self.repository.update_review_case_status(item["source_id"], status="open", owner_id=owner_id) + if item.get("source_type") == "campaign_review_case": + submission = self.repository.get_campaign_review_submission(item["source_id"]) + if submission.get("review_case_id"): + self.repository.update_review_case_status(submission["review_case_id"], status="open", owner_id=owner_id) + if item.get("source_type") == "support_case": + case = self.repository.get_support_case(item["source_id"]) + self.repository.save_support_case({**case, "owner_id": owner_id, "support_payload_json": case.get("support_payload_json", {})}) + if item.get("source_type") == "dispute": + dispute = self.repository.get_dispute(item["source_id"]) + self.repository.save_dispute({**dispute, "reviewer_id": owner_id, "dispute_payload_json": dispute.get("dispute_payload_json", {})}) + return self.review_item_detail(updated["review_item_id"]) + + def update_review_item_status(self, *, review_item_id: str, status: str, reviewer_id: str) -> Dict[str, Any]: + normalized_status = str(status or "").strip() + if normalized_status not in VALID_REVIEW_ITEM_STATUSES: + raise ValueError("invalid_review_item_status") + if not str(reviewer_id or "").strip(): + raise ValueError("reviewer_id_required") + item = self.repository.get_ops_review_item(review_item_id) + updated = self.repository.save_ops_review_item( + { + **item, + "status": normalized_status, + "reviewer_id": reviewer_id, + } + ) + if item.get("source_type") == "quality_review_case": + case_status = { + "new": "open", + "triaged": "open", + "in_review": "in_review", + "resolved": "resolved", + "dismissed": "dismissed", + "blocked": "open", + "approved": "resolved", + "needs_changes": "open", + }.get(normalized_status, "open") + self.repository.update_review_case_status(item["source_id"], status=case_status) + if item.get("source_type") == "campaign_review_case": + submission = self.repository.get_campaign_review_submission(item["source_id"]) + case_status = { + "new": "open", + "triaged": "open", + "in_review": "in_review", + "resolved": "resolved", + "dismissed": "dismissed", + "blocked": "resolved", + "approved": "resolved", + "needs_changes": "resolved", + }.get(normalized_status, "open") + if submission.get("review_case_id"): + self.repository.update_review_case_status(submission["review_case_id"], status=case_status) + submission_status = "in_review" if normalized_status == "in_review" else submission.get("status") + self.repository.save_campaign_review_submission({**submission, "status": submission_status}) + if item.get("source_type") == "support_case": + case = self.repository.get_support_case(item["source_id"]) + mapped_status = { + "new": "open", + "triaged": "open", + "in_review": "in_progress", + "resolved": "resolved", + "dismissed": "dismissed", + }.get(normalized_status, "open") + self.repository.save_support_case({**case, "status": mapped_status, "support_payload_json": case.get("support_payload_json", {})}) + if item.get("source_type") == "dispute": + dispute = self.repository.get_dispute(item["source_id"]) + mapped_status = { + "new": "open", + "triaged": "under_review", + "in_review": "under_review", + "resolved": "resolved", + "dismissed": "rejected", + }.get(normalized_status, "open") + self.repository.save_dispute({**dispute, "status": mapped_status, "dispute_payload_json": dispute.get("dispute_payload_json", {})}) + return self.review_item_detail(updated["review_item_id"]) + + def decide_review_item(self, *, review_item_id: str, decision: str, reviewer_id: str) -> Dict[str, Any]: + normalized_decision = str(decision or "").strip() + if normalized_decision not in VALID_REVIEW_ITEM_DECISIONS: + raise ValueError("invalid_review_item_decision") + result = self.update_review_item_status( + review_item_id=review_item_id, + status=VALID_REVIEW_ITEM_DECISIONS[normalized_decision], + reviewer_id=reviewer_id, + ) + review_item = result["review_item"] + if review_item.get("source_type") == "quality_review_case": + case_status = { + "resolve": "resolved", + "dismiss": "dismissed", + }.get(normalized_decision) + if case_status: + self.repository.update_review_case_status(review_item["source_id"], status=case_status) + return self.review_item_detail(review_item_id) + if review_item.get("source_type") == "campaign_review_case": + submission = self.repository.get_campaign_review_submission(review_item["source_id"]) + campaign_id = str(submission.get("campaign_id") or "") + mapped = { + "approve": "approve", + "needs_changes": "needs_changes", + "block": "block", + }.get(normalized_decision) + if mapped: + self.customer_campaigns.decide_campaign( + campaign_id=campaign_id, + reviewer_id=reviewer_id, + decision=mapped, + ) + return self.review_item_detail(review_item_id) + if review_item.get("source_type") == "author_work": + work = self.repository.get_author_work(review_item["source_id"]) + next_work_status = { + "approve": "approved", + "needs_changes": "needs_changes", + "block": "needs_changes", + }.get(normalized_decision) + if next_work_status: + self.repository.save_author_work( + { + **work, + "status": next_work_status, + } + ) + return self.review_item_detail(review_item_id) + return result diff --git a/src/narrativeos/services/ops_traceability.py b/src/narrativeos/services/ops_traceability.py index 11b5bb0..98b2b10 100644 --- a/src/narrativeos/services/ops_traceability.py +++ b/src/narrativeos/services/ops_traceability.py @@ -517,6 +517,62 @@ def _world_release_trace_entries(self, world_version_id: str, *, account_id: str link_tokens=[f"world_version:{world_version_id}"], ) ) + capability = dict(status.get("author_longform_capability") or {}) + readiness = dict(capability.get("longform_readiness") or {}) + entries.append( + self._trace_entry( + trace_id=f"author_longform_capability::{world_version_id}", + occurred_at=status.get("versions", [{}])[0].get("updated_at") if status.get("versions") else None, + source_type="author_longform_capability", + category="content_release", + severity="info" if readiness.get("status") in {None, "ready"} else "high", + status=readiness.get("status") or "unknown", + headline="author_longform_capability", + summary=f"entry {capability.get('entry_mode') or '-'} · requested {capability.get('requested_target_band') or '-'} · claim {capability.get('claim_safe_band') or '-'}", + account_id=account_id, + world_version_id=world_version_id, + object_type="author_longform_capability", + object_id=world_version_id, + evidence_refs=[ + self._evidence_ref( + kind="author_longform_capability", + label="author_longform_capability", + ref_id=world_version_id, + preview=str(capability or {"claim": "not_asserted"}), + ) + ], + next_actions=list(readiness.get("recommended_actions") or []), + link_tokens=[f"world_version:{world_version_id}"], + ) + ) + if status.get("author_claim_alignment"): + alignment = dict(status.get("author_claim_alignment") or {}) + entries.append( + self._trace_entry( + trace_id=f"author_longform_claim_alignment::{world_version_id}", + occurred_at=status.get("versions", [{}])[0].get("updated_at") if status.get("versions") else None, + source_type="author_longform_claim_alignment", + category="content_release", + severity="info" if alignment.get("aligned") else "high", + status="ok" if alignment.get("aligned") else "blocked", + headline="author_longform_claim_alignment", + summary=f"claim {alignment.get('claim_safe_band') or '-'} · ops ready {alignment.get('ops_release_ready_band') or '-'} · aligned {'yes' if alignment.get('aligned') else 'no'}", + account_id=account_id, + world_version_id=world_version_id, + object_type="author_longform_claim_alignment", + object_id=world_version_id, + evidence_refs=[ + self._evidence_ref( + kind="author_longform_claim_alignment", + label="author_longform_claim_alignment", + ref_id=world_version_id, + preview=str(alignment), + ) + ], + next_actions=["inspect_release_evidence_bundle"] if not alignment.get("aligned") else [], + link_tokens=[f"world_version:{world_version_id}"], + ) + ) for item in history.get("rollback_drilldown", []): entries.append( self._trace_entry( @@ -548,6 +604,25 @@ def _world_release_trace_entries(self, world_version_id: str, *, account_id: str ) return entries + def _longform_alignment_snapshot(self, world_version_id: Optional[str]) -> Dict[str, Any]: + if not world_version_id: + return { + "author_longform_capability": {}, + "author_claim_alignment": {}, + } + try: + version = self.repository.get_world_version(world_version_id) + except KeyError: + return { + "author_longform_capability": {}, + "author_claim_alignment": {}, + } + status = self.review.world_status(version.world_id) + return { + "author_longform_capability": dict(status.get("author_longform_capability") or {}), + "author_claim_alignment": dict(status.get("author_claim_alignment") or {}), + } + def _runtime_trace_entries(self, *, account_id: str, world_version_id: Optional[str], limit: int) -> List[Dict[str, Any]]: receipts = self.observability.list_runtime_receipts(account_id=account_id, limit=max(limit * 4, 50)) if world_version_id: @@ -629,12 +704,71 @@ def _build_evidence_index(self, trace_timeline: List[Dict[str, Any]]) -> List[Di ) return items + def _preserve_source_type_coverage( + self, + trace_timeline: List[Dict[str, Any]], + *, + limit: int, + ) -> List[Dict[str, Any]]: + if limit <= 0: + return [] + if len(trace_timeline) <= limit: + return list(trace_timeline) + selected = list(trace_timeline[:limit]) + present_source_types = { + str(trace.get("source_type") or "") + for trace in selected + if str(trace.get("source_type") or "") + } + missing_by_type: Dict[str, Dict[str, Any]] = {} + for trace in trace_timeline[limit:]: + source_type = str(trace.get("source_type") or "") + if source_type and source_type not in present_source_types and source_type not in missing_by_type: + missing_by_type[source_type] = trace + if not missing_by_type: + return selected + selected.extend(missing_by_type.values()) + selected = sorted( + selected, + key=lambda item: (self._parse_timestamp(item.get("occurred_at")), -len(item.get("evidence_refs", []))), + reverse=True, + ) + required_ids = { + str(trace.get("trace_id") or "") + for trace in missing_by_type.values() + if str(trace.get("trace_id") or "") + } + while len(selected) > limit: + source_counts: Dict[str, int] = {} + for trace in selected: + source_type = str(trace.get("source_type") or "") + source_counts[source_type] = source_counts.get(source_type, 0) + 1 + removed = False + for index in range(len(selected) - 1, -1, -1): + trace = selected[index] + trace_id = str(trace.get("trace_id") or "") + source_type = str(trace.get("source_type") or "") + if trace_id in required_ids: + continue + if source_counts.get(source_type, 0) <= 1: + continue + selected.pop(index) + source_counts[source_type] -= 1 + removed = True + break + if not removed: + selected = selected[:limit] + break + return selected[:limit] + def _investigation_summary( self, *, trace_timeline: List[Dict[str, Any]], account_detail: Dict[str, Any], governance_snapshot: Dict[str, Any], + author_longform_capability: Optional[Dict[str, Any]] = None, + author_claim_alignment: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: category_counts: Dict[str, int] = {} severity_counts: Dict[str, int] = {} @@ -652,6 +786,11 @@ def _investigation_summary( "open_support_issue_count": int(account_detail.get("support_summary", {}).get("open_issue_count") or 0), "billing_retry_attempt_count": int(account_detail.get("lifecycle_history_summary", {}).get("retry_attempt_count") or 0), "billing_event_count": int(account_detail.get("lifecycle_history_summary", {}).get("event_count") or 0), + "author_entry_mode": dict(author_longform_capability or {}).get("entry_mode"), + "author_claim_safe_band": dict(author_longform_capability or {}).get("claim_safe_band"), + "author_requested_target_band": dict(author_longform_capability or {}).get("requested_target_band"), + "ops_release_ready_band": dict(author_claim_alignment or {}).get("ops_release_ready_band"), + "author_claim_alignment": bool(dict(author_claim_alignment or {}).get("aligned")) if author_claim_alignment else None, } def investigate_account( @@ -666,6 +805,7 @@ def investigate_account( governance_snapshot = self.governance.account_snapshot(account_id=account_id, limit=limit) support_issues = account_detail.get("support_issues", []) world_version_ids = [world_version_id] if world_version_id else self._world_versions_for_account(account_id) + alignment_snapshot = self._longform_alignment_snapshot(world_version_id or (world_version_ids[0] if len(world_version_ids) == 1 else None)) trace_timeline = [ *self._billing_trace_entries(account_detail), @@ -686,7 +826,8 @@ def investigate_account( trace_timeline, key=lambda item: (self._parse_timestamp(item.get("occurred_at")), -len(item.get("evidence_refs", []))), reverse=True, - )[:limit] + ) + trace_timeline = self._preserve_source_type_coverage(trace_timeline, limit=limit) trace_timeline = self._link_trace_timeline(trace_timeline) evidence_index = self._build_evidence_index(trace_timeline) recommended_paths = self._recommended_paths( @@ -707,6 +848,8 @@ def investigate_account( trace_timeline=trace_timeline, account_detail=account_detail, governance_snapshot=governance_snapshot, + author_longform_capability=alignment_snapshot.get("author_longform_capability"), + author_claim_alignment=alignment_snapshot.get("author_claim_alignment"), ), "linked_entities": { "account_id": account_detail.get("account_id"), @@ -716,6 +859,8 @@ def investigate_account( "world_version_ids": world_version_ids, "support_issue_ids": [item.get("issue_id") for item in support_issues], }, + "author_longform_capability": alignment_snapshot.get("author_longform_capability") or {}, + "author_claim_alignment": alignment_snapshot.get("author_claim_alignment") or {}, "trace_timeline": trace_timeline, "evidence_index": evidence_index, "recommended_paths": recommended_paths, @@ -723,6 +868,9 @@ def investigate_account( "account_id": account_id, "world_version_id": world_version_id, "case_id": case_id, + "claim_safe_band": dict(alignment_snapshot.get("author_longform_capability") or {}).get("claim_safe_band"), + "ops_release_ready_band": dict(alignment_snapshot.get("author_claim_alignment") or {}).get("ops_release_ready_band"), + "author_claim_alignment": dict(alignment_snapshot.get("author_claim_alignment") or {}).get("aligned"), }, } diff --git a/src/narrativeos/services/paid_pilot_acceptance.py b/src/narrativeos/services/paid_pilot_acceptance.py new file mode 100644 index 0000000..3b4f581 --- /dev/null +++ b/src/narrativeos/services/paid_pilot_acceptance.py @@ -0,0 +1,1153 @@ +from __future__ import annotations + +import json +import re +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + + +ACCEPTED_STATUSES = {"ok", "pass", "passed", "ready", "completed"} +FORMAL_PILOT_APP_URL = "https://pilot.lixidol.com" +FORMAL_PILOT_DOMAIN = "pilot.lixidol.com" +REMOTE_PRODUCT_SMOKE_PATH = "artifacts/vercel_remote_acceptance/latest/product_smoke.json" +REMOTE_READER_PAID_PATH_SMOKE_PATH = "artifacts/reader_paid_path_smoke_result.json" +REMOTE_AUTHOR_APPROVAL_SMOKE_PATH = "artifacts/author_remote_approval_flow/latest/summary.json" +REMOTE_OPS_SMOKE_PATH = "artifacts/quantum_ops_url_state_smoke_result.json" +REMOTE_VERCEL_PERFORMANCE_PATH = "artifacts/vercel_remote_acceptance/latest/performance.json" +REMOTE_NPM_AUDIT_PATH = "artifacts/vercel_remote_acceptance/latest/npm_audit.json" +REMOTE_DATABASE_READINESS_PATH = "artifacts/vercel_remote_acceptance/latest/database_readiness.json" +REMOTE_DATABASE_LOAD_SMOKE_PATH = "artifacts/vercel_remote_acceptance/latest/database_load_smoke.json" +REMOTE_READER_QUALITY_SAMPLE_PATH = "artifacts/vercel_remote_acceptance/latest/reader_quality_sample.json" +LAUNCH_WEEK_MONITORING_PATH = "artifacts/launch_week_monitoring/latest/summary.json" +LANE_OUTPUT_DIRS = { + "A": Path("artifacts/lane_a_1_12_500_ready_promotion/latest"), + "B": Path("artifacts/author_repair_efficiency/latest"), + "C": Path("artifacts/paid_pilot_acceptance/latest/lane_c_billing_credits_audit"), + "D": Path("artifacts/ops_pilot_console/latest"), + "F": Path("artifacts/runtime_pilot_evidence/latest"), +} + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _artifact_path(base_dir: Path, relative_path: str) -> Path: + return base_dir / relative_path + + +def _relative(base_dir: Path, path: Path) -> str: + try: + return str(path.relative_to(base_dir)) + except ValueError: + return str(path) + + +def _read_json(base_dir: Path, relative_path: str) -> Dict[str, Any]: + path = _artifact_path(base_dir, relative_path) + if not path.exists(): + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {"_read_error": True} + return payload if isinstance(payload, dict) else {"_payload": payload} + + +def _read_text(base_dir: Path, relative_path: str) -> str: + path = _artifact_path(base_dir, relative_path) + if not path.exists(): + return "" + try: + return path.read_text(encoding="utf-8") + except Exception: + return "" + + +def _first_existing_relative(base_dir: Path, candidates: Iterable[str]) -> str: + ordered = list(candidates) + for relative_path in ordered: + if _artifact_path(base_dir, relative_path).exists(): + return relative_path + return ordered[0] if ordered else "" + + +def _artifact(base_dir: Path, relative_path: str) -> Dict[str, Any]: + path = _artifact_path(base_dir, relative_path) + return { + "path": relative_path, + "exists": path.exists(), + "size_bytes": path.stat().st_size if path.exists() else 0, + } + + +def _accepted_status(value: Any) -> bool: + return str(value or "").strip().lower() in ACCEPTED_STATUSES + + +def _int_from_match(pattern: str, text: str, default: int = 0) -> int: + match = re.search(pattern, text, flags=re.IGNORECASE) + if not match: + return default + try: + return int(match.group(1)) + except (TypeError, ValueError): + return default + + +def _line_list(pattern: str, text: str) -> List[str]: + match = re.search(pattern, text, flags=re.IGNORECASE) + if not match: + return [] + value = match.group(1).strip() + if not value or value == "-": + return [] + return [item.strip() for item in value.split(",") if item.strip() and item.strip() != "-"] + + +def _lane_status(blockers: Iterable[Dict[str, Any]]) -> str: + blocker_list = list(blockers) + if any(str(item.get("severity") or "") == "missing" for item in blocker_list): + return "missing" + return "blocked" if blocker_list else "passed" + + +def _blocker(key: str, detail: str, *, severity: str = "high", **extra: Any) -> Dict[str, Any]: + payload = {"key": key, "severity": severity, "detail": detail} + payload.update(extra) + return payload + + +def _looks_local_url(value: Optional[str]) -> bool: + raw = str(value or "").strip().lower() + return raw.startswith("http://127.0.0.1") or raw.startswith("http://localhost") or "://0.0.0.0" in raw + + +def _domain_from_url(value: Optional[str]) -> str: + raw = str(value or "").strip() + if not raw: + return "" + try: + return urllib.parse.urlparse(raw).hostname or "" + except Exception: + return "" + + +def _resolve_vercel_deployment_id(base_dir: Path, explicit: Optional[str]) -> Optional[str]: + if explicit: + return explicit + binding = _read_json(base_dir, "artifacts/vercel_remote_acceptance/latest/domain_binding.json") + deployment_id = str(binding.get("deployment_id") or "").strip() + return deployment_id or None + + +def _string_contains_secret(value: str) -> bool: + lowered = value.lower() + secret_markers = [ + "bearer ", + "authorization:", + "access_token", + "refresh_token", + "database_url", + "database-url", + "postgres://", + "postgresql://", + "mysql://", + "neon.tech/", + ] + return any(marker in lowered for marker in secret_markers) + + +def _payload_contains_secret(payload: Any) -> bool: + if isinstance(payload, dict): + for key, value in payload.items(): + if _string_contains_secret(str(key)): + return True + if _payload_contains_secret(value): + return True + return False + if isinstance(payload, list): + return any(_payload_contains_secret(item) for item in payload) + if isinstance(payload, str): + return _string_contains_secret(payload) + return False + + +def _lane_result( + *, + lane: str, + task: str, + title: str, + evidence: Dict[str, Any], + blockers: List[Dict[str, Any]], + artifacts: List[Dict[str, Any]], + recommended_actions: Optional[List[str]] = None, +) -> Dict[str, Any]: + return { + "lane": lane, + "task": task, + "title": title, + "status": _lane_status(blockers), + "ready": not blockers, + "evidence": evidence, + "blockers": blockers, + "artifacts": artifacts, + "recommended_actions": recommended_actions or ([] if not blockers else [f"close_{task.lower().replace(' ', '_')}"]), + } + + +def _latest_file(base_dir: Path, pattern: str) -> Optional[Path]: + files = [path for path in base_dir.glob(pattern) if path.is_file()] + if not files: + return None + return max(files, key=lambda path: path.stat().st_mtime) + + +def build_runtime_pilot_evidence( + *, + base_dir: Path, + health_url: str = "http://127.0.0.1:8000/health", + remote_only: bool = False, +) -> Dict[str, Any]: + runbook_text = _read_text(base_dir, "docs/deployment_runbook.md") + latest_backup_manifest = _latest_file(base_dir, "artifacts/runtime_backups/*.json") + latest_restore_result = _latest_file(base_dir, "artifacts/runtime_postgres_ops/job_*/result.json") + restore_payload: Dict[str, Any] = {} + if latest_restore_result is not None: + try: + restore_payload = json.loads(latest_restore_result.read_text(encoding="utf-8")) + except Exception: + restore_payload = {"_read_error": True} + + health_payload: Dict[str, Any] = {"status": "not_checked", "url": health_url} + try: + with urllib.request.urlopen(health_url, timeout=2.0) as response: + raw = response.read().decode("utf-8", errors="replace") + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = {"raw": raw} + health_payload = { + "status": "ok" if str(parsed.get("status") or "").lower() == "ok" else "unexpected", + "url": health_url, + "payload": parsed, + } + except (urllib.error.URLError, TimeoutError, OSError) as exc: + health_payload = {"status": "unreachable", "url": health_url, "error": str(exc)} + + checks = { + "backend_local_script_present": (base_dir / "scripts/run_backend_local.sh").exists(), + "fixed_port_runbook_present": "http://127.0.0.1:3000" in runbook_text and "http://127.0.0.1:8000" in runbook_text, + "health_endpoint_ok": health_payload.get("status") == "ok", + "backup_manifest_present": latest_backup_manifest is not None, + "restore_request_evidence_present": latest_restore_result is not None + and str(restore_payload.get("action") or "") == "restore" + and str(restore_payload.get("status") or "") == "completed", + "production_restore_manual_only": "Do not paste live secrets" in runbook_text + and "Production-only confirmations stay manual" in runbook_text, + } + if remote_only: + checks["formal_domain_health_endpoint"] = not _looks_local_url(health_url) and health_payload.get("status") == "ok" + checks["remote_only_acceptance_runbook_present"] = ( + "pilot.lixidol.com" in runbook_text + and "REMOTE_MODE=1" in runbook_text + and "商业化" in runbook_text + ) + blockers = [ + _blocker(key, "Runtime pilot evidence check did not pass.") + for key, passed in checks.items() + if not passed and not (remote_only and key in {"backend_local_script_present", "fixed_port_runbook_present"}) + ] + return { + "schema_version": "runtime_pilot_evidence/v1", + "generated_at": _utc_now(), + "acceptance_source": "formal_domain_remote" if remote_only else "local_runtime", + "status": _lane_status(blockers), + "ready": not blockers, + "fixed_ports": {"frontend": 3000, "backend": 8000}, + "checks": checks, + "health": health_payload, + "latest_backup_manifest": _relative(base_dir, latest_backup_manifest) if latest_backup_manifest else None, + "latest_restore_result": _relative(base_dir, latest_restore_result) if latest_restore_result else None, + "blockers": blockers, + } + + +def _optional_artifact_json(base_dir: Path, relative_path: Optional[str]) -> Dict[str, Any]: + if not relative_path: + return {} + return _read_json(base_dir, relative_path) + + +def _has_collection_errors(payload: Dict[str, Any], keys: Iterable[str]) -> bool: + return any(bool(payload.get(key)) for key in keys) + + +def build_remote_vercel_acceptance( + *, + base_dir: Path, + public_app_url: Optional[str] = None, + vercel_deployment_id: Optional[str] = None, + vercel_domain: Optional[str] = None, + remote_smoke_summary_path: Optional[str] = None, + reader_paid_path_summary_path: Optional[str] = None, + author_approval_summary_path: Optional[str] = None, + ops_remote_smoke_summary_path: Optional[str] = None, + vercel_performance_summary_path: Optional[str] = None, + npm_audit_summary_path: Optional[str] = None, + database_readiness_summary_path: Optional[str] = None, + database_load_smoke_summary_path: Optional[str] = None, + reader_quality_sample_summary_path: Optional[str] = None, + launch_week_monitoring_summary_path: Optional[str] = None, +) -> Dict[str, Any]: + smoke = _optional_artifact_json(base_dir, remote_smoke_summary_path) + reader_paid = _optional_artifact_json(base_dir, reader_paid_path_summary_path) + author_approval = _optional_artifact_json(base_dir, author_approval_summary_path) + ops_remote = _optional_artifact_json(base_dir, ops_remote_smoke_summary_path) + performance = _optional_artifact_json(base_dir, vercel_performance_summary_path) + npm_audit = _optional_artifact_json(base_dir, npm_audit_summary_path) + database_readiness = _optional_artifact_json(base_dir, database_readiness_summary_path) + database_load_smoke = _optional_artifact_json(base_dir, database_load_smoke_summary_path) + reader_quality_sample = _optional_artifact_json(base_dir, reader_quality_sample_summary_path) + launch_week_monitoring = _optional_artifact_json(base_dir, launch_week_monitoring_summary_path) + blockers: List[Dict[str, Any]] = [] + production_high_count = int(npm_audit.get("production_high_count", 0) or 0) if npm_audit else None + resolved_deployment_id = _resolve_vercel_deployment_id(base_dir, vercel_deployment_id) + + if public_app_url: + if _looks_local_url(public_app_url): + blockers.append(_blocker("formal_domain_url_required", "Commercial ready acceptance must use the formal external URL, not localhost.")) + if _domain_from_url(public_app_url) != FORMAL_PILOT_DOMAIN: + blockers.append(_blocker("formal_domain_mismatch", "Commercial ready acceptance must target pilot.lixidol.com.")) + if not resolved_deployment_id: + blockers.append(_blocker("vercel_deployment_id_missing", "Formal-domain acceptance must record the Vercel production deployment id.")) + + if not remote_smoke_summary_path or not smoke: + blockers.append(_blocker("remote_product_smoke_missing", "Formal-domain browser/API smoke evidence is required.")) + elif not _accepted_status(smoke.get("status")) or _has_collection_errors( + smoke, + ["api_errors", "browser_response_errors", "console_errors", "layout_errors"], + ): + blockers.append(_blocker("remote_product_smoke_failed", "Formal-domain product smoke must pass without API, console, or layout errors.")) + elif _payload_contains_secret(smoke): + blockers.append(_blocker("remote_product_smoke_secret_leak", "Remote product smoke artifact must not contain tokens, cookies, DB URLs, or raw secrets.")) + + if not reader_quality_sample_summary_path or not reader_quality_sample: + blockers.append(_blocker("remote_reader_quality_sample_missing", "Formal-domain Reader generated quality sample evidence is required.")) + else: + if not bool(reader_quality_sample.get("ready", False)) or not _accepted_status(reader_quality_sample.get("status")): + blockers.append( + _blocker( + "remote_reader_quality_sample_not_ready", + "Formal-domain Reader generated sample must have no broken slots, engineering/meta leaks, or Q03/choice regressions.", + violation_count=reader_quality_sample.get("violation_count"), + issue_counts=reader_quality_sample.get("issue_counts"), + ) + ) + if _looks_local_url(reader_quality_sample.get("public_app_url") or reader_quality_sample.get("api_origin")): + blockers.append(_blocker("remote_reader_quality_sample_local_url", "Reader quality sample must come from the formal domain.")) + if _payload_contains_secret(reader_quality_sample): + blockers.append(_blocker("remote_reader_quality_sample_secret_leak", "Reader quality sample artifact must not contain tokens, cookies, DB URLs, or raw secrets.")) + + if not reader_paid_path_summary_path or not reader_paid: + blockers.append(_blocker("remote_reader_paid_path_smoke_missing", "Formal-domain Reader paid-path smoke evidence is required.")) + else: + runtime = dict(reader_paid.get("runtime") or {}) + remote_mode = bool(reader_paid.get("remote_mode", False) or runtime.get("remote_mode", False)) + if not _accepted_status(reader_paid.get("status")): + blockers.append(_blocker("remote_reader_paid_path_smoke_failed", "Reader paid-path remote smoke must pass.")) + if not remote_mode: + blockers.append(_blocker("remote_reader_paid_path_not_remote", "Reader paid-path smoke must run in remote mode for commercial acceptance.")) + if reader_paid.get("schema_version") != "reader_paid_path_smoke/v2": + blockers.append(_blocker("remote_reader_paid_path_schema_outdated", "Reader paid-path smoke must use v2 remote-only artifact schema.")) + if _looks_local_url(runtime.get("public_app_url") or reader_paid.get("public_app_url")): + blockers.append(_blocker("remote_reader_paid_path_local_url", "Reader paid-path artifact cannot be based on localhost.")) + if reader_paid.get("database_ref") or runtime.get("database_ref"): + blockers.append(_blocker("remote_reader_paid_path_db_ref_present", "Reader paid-path remote smoke must not record or depend on database refs.")) + if not dict(reader_paid.get("paywall") or {}).get("required"): + blockers.append(_blocker("remote_reader_paid_path_paywall_missing", "Reader paid-path remote smoke must prove payment_required before checkout.")) + if str(reader_paid.get("subscription_status") or "") != "active": + blockers.append(_blocker("remote_reader_paid_path_subscription_inactive", "Reader paid-path remote smoke must activate subscription after checkout.")) + if _payload_contains_secret(reader_paid): + blockers.append(_blocker("remote_reader_paid_path_secret_leak", "Reader paid-path artifact must not contain tokens, cookies, DB URLs, or raw secrets.")) + + if not author_approval_summary_path or not author_approval: + blockers.append(_blocker("remote_author_approval_smoke_missing", "Formal-domain Author/Reviewer approval smoke evidence is required.")) + else: + author_runtime = dict(author_approval.get("runtime") or {}) + if not _accepted_status(author_approval.get("status")) or _has_collection_errors(author_approval, ["api_errors", "console_errors", "browser_response_errors"]): + blockers.append(_blocker("remote_author_approval_smoke_failed", "Formal-domain Author/Reviewer approval smoke must pass without API or browser errors.")) + if not bool(author_approval.get("remote_mode", False) or author_runtime.get("remote_mode", False)): + blockers.append(_blocker("remote_author_approval_not_remote", "Author approval smoke must run against the formal domain for commercial acceptance.")) + if _looks_local_url(author_approval.get("public_app_url") or author_runtime.get("public_app_url") or author_runtime.get("api_origin")): + blockers.append(_blocker("remote_author_approval_local_url", "Author approval smoke artifact cannot be based on localhost.")) + if _payload_contains_secret(author_approval): + blockers.append(_blocker("remote_author_approval_secret_leak", "Author approval smoke artifact must not contain tokens, cookies, DB URLs, or raw secrets.")) + + if not ops_remote_smoke_summary_path or not ops_remote: + blockers.append(_blocker("remote_ops_smoke_missing", "Formal-domain Ops smoke evidence is required.")) + else: + if not _accepted_status(ops_remote.get("status")) or _has_collection_errors(ops_remote, ["api_errors", "browser_response_errors", "console_errors", "layout_errors"]): + blockers.append(_blocker("remote_ops_smoke_failed", "Formal-domain Ops smoke must pass without API, console, or layout errors.")) + if not bool(ops_remote.get("remote_mode", False)): + blockers.append(_blocker("remote_ops_smoke_not_remote", "Ops smoke must run against the formal domain for commercial acceptance.")) + if _looks_local_url(ops_remote.get("public_app_url") or ops_remote.get("api_origin")): + blockers.append(_blocker("remote_ops_smoke_local_url", "Ops smoke artifact cannot be based on localhost.")) + if _payload_contains_secret(ops_remote): + blockers.append(_blocker("remote_ops_smoke_secret_leak", "Ops smoke artifact must not contain tokens, cookies, DB URLs, or raw secrets.")) + + if not vercel_performance_summary_path or not performance: + blockers.append(_blocker("vercel_performance_summary_missing", "Vercel bundle/cold-start performance evidence is required.")) + elif not bool(performance.get("ready", False)): + blockers.append(_blocker("vercel_performance_not_ready", "Vercel bundle/cold-start checks did not pass.")) + + if not npm_audit_summary_path or not npm_audit: + blockers.append(_blocker("npm_audit_summary_missing", "npm audit evidence is required for remote acceptance.")) + elif production_high_count != 0: + blockers.append( + _blocker( + "npm_production_high_vulnerabilities_present", + "Production npm audit high vulnerabilities must be zero.", + production_high_count=production_high_count, + ) + ) + + if not database_readiness_summary_path or not database_readiness: + blockers.append(_blocker("public_beta_database_readiness_missing", "Public Beta database readiness evidence is required.")) + elif not bool(database_readiness.get("ready", False)): + blockers.append( + _blocker( + "public_beta_database_not_ready", + "Public Beta requires pooled Postgres, direct migration URL, read-replica evidence, and SQLite failover disabled.", + failed_checks=[ + key + for key, passed in dict(database_readiness.get("checks") or {}).items() + if not passed + ], + ) + ) + + if not database_load_smoke_summary_path or not database_load_smoke: + blockers.append(_blocker("public_beta_database_load_smoke_missing", "Public Beta database load smoke evidence is required.")) + elif not bool(database_load_smoke.get("ready", False)): + blockers.append( + _blocker( + "public_beta_database_load_smoke_failed", + "Public Beta database load smoke must pass without quota, connection, or latency failures.", + error_count=database_load_smoke.get("error_count"), + read_p95_ms=database_load_smoke.get("read_p95_ms"), + write_p95_ms=database_load_smoke.get("write_p95_ms"), + ) + ) + + if not launch_week_monitoring_summary_path or not launch_week_monitoring: + blockers.append(_blocker("launch_week_monitoring_missing", "Launch-week remote monitoring closure evidence is required.")) + else: + monitoring_deployment_id = str(launch_week_monitoring.get("vercel_deployment_id") or "").strip() + if not monitoring_deployment_id: + blockers.append(_blocker("launch_week_monitoring_deployment_id_missing", "Launch-week monitoring summary must record the Vercel deployment id.")) + elif resolved_deployment_id and monitoring_deployment_id != resolved_deployment_id: + blockers.append( + _blocker( + "launch_week_monitoring_deployment_id_mismatch", + "Launch-week monitoring deployment id must match the paid acceptance deployment id.", + monitoring_deployment_id=monitoring_deployment_id, + acceptance_deployment_id=resolved_deployment_id, + ) + ) + if not bool(launch_week_monitoring.get("ready_to_expand", False)): + blockers.append(_blocker("launch_week_monitoring_not_ready_to_expand", "Launch-week monitoring is red; pause new invite expansion.")) + if _payload_contains_secret(launch_week_monitoring): + blockers.append(_blocker("launch_week_monitoring_secret_leak", "Launch-week monitoring artifact must not contain tokens, cookies, DB URLs, or raw secrets.")) + + return { + "schema_version": "remote_vercel_acceptance/v1", + "generated_at": _utc_now(), + "public_app_url": public_app_url, + "vercel_domain": vercel_domain, + "vercel_deployment_id": resolved_deployment_id, + "ready": not blockers, + "status": "passed" if not blockers else "blocked", + "remote_smoke": { + "path": remote_smoke_summary_path, + "status": smoke.get("status"), + "completed_step_count": len(list(smoke.get("completed_steps") or [])), + "api_error_count": len(list(smoke.get("api_errors") or [])), + "browser_response_error_count": len(list(smoke.get("browser_response_errors") or [])), + "console_error_count": len(list(smoke.get("console_errors") or [])), + "layout_error_count": len(list(smoke.get("layout_errors") or [])), + }, + "reader_quality_sample": { + "path": reader_quality_sample_summary_path, + "status": reader_quality_sample.get("status"), + "ready": bool(reader_quality_sample.get("ready", False)), + "sample_node_count": reader_quality_sample.get("sample_node_count"), + "violation_count": reader_quality_sample.get("violation_count"), + "issue_counts": reader_quality_sample.get("issue_counts"), + }, + "reader_paid_path": { + "path": reader_paid_path_summary_path, + "status": reader_paid.get("status"), + "schema_version": reader_paid.get("schema_version"), + "remote_mode": bool(reader_paid.get("remote_mode", False) or dict(reader_paid.get("runtime") or {}).get("remote_mode", False)), + "paywall_required": bool(dict(reader_paid.get("paywall") or {}).get("required")), + "subscription_status": reader_paid.get("subscription_status"), + "cleanup": dict(reader_paid.get("cleanup") or {}), + }, + "author_approval": { + "path": author_approval_summary_path, + "status": author_approval.get("status"), + "remote_mode": bool(author_approval.get("remote_mode", False) or dict(author_approval.get("runtime") or {}).get("remote_mode", False)), + "completed_step_count": len(list(author_approval.get("completed_steps") or author_approval.get("steps") or [])), + "ops_audit_hit_count": author_approval.get("ops_audit_hit_count") or dict(author_approval.get("summary") or {}).get("ops_audit_hit_count"), + }, + "ops_remote_smoke": { + "path": ops_remote_smoke_summary_path, + "status": ops_remote.get("status"), + "remote_mode": bool(ops_remote.get("remote_mode", False)), + "completed_step_count": len(list(ops_remote.get("completed_steps") or [])), + "cleanup": dict(ops_remote.get("cleanup") or {}), + }, + "performance": { + "path": vercel_performance_summary_path, + "ready": performance.get("ready"), + "bundle_mb": performance.get("bundle_mb"), + "health_hot_ms": performance.get("health_hot_ms"), + "cold_start_5xx_count": performance.get("cold_start_5xx_count"), + }, + "npm_audit": { + "path": npm_audit_summary_path, + "production_high_count": production_high_count, + "full_high_count": npm_audit.get("full_high_count") if npm_audit else None, + "risk_register": npm_audit.get("risk_register") if npm_audit else None, + }, + "database_readiness": { + "path": database_readiness_summary_path, + "ready": database_readiness.get("ready") if database_readiness else None, + "status": database_readiness.get("status") if database_readiness else None, + "failed_checks": [ + key + for key, passed in dict(database_readiness.get("checks") or {}).items() + if not passed + ] if database_readiness else [], + "runtime_database": dict(database_readiness.get("runtime_database") or {}) if database_readiness else {}, + "read_replica_configured": bool( + dict(database_readiness.get("read_replica_database") or {}).get("configured") + ) if database_readiness else False, + "sqlite_failover": dict(database_readiness.get("sqlite_failover") or {}) if database_readiness else {}, + }, + "database_load_smoke": { + "path": database_load_smoke_summary_path, + "ready": database_load_smoke.get("ready") if database_load_smoke else None, + "status": database_load_smoke.get("status") if database_load_smoke else None, + "read_concurrency": database_load_smoke.get("read_concurrency") if database_load_smoke else None, + "write_concurrency": database_load_smoke.get("write_concurrency") if database_load_smoke else None, + "error_count": database_load_smoke.get("error_count") if database_load_smoke else None, + "read_p95_ms": database_load_smoke.get("read_p95_ms") if database_load_smoke else None, + "write_p95_ms": database_load_smoke.get("write_p95_ms") if database_load_smoke else None, + }, + "launch_week_monitoring": { + "path": launch_week_monitoring_summary_path, + "status": launch_week_monitoring.get("status"), + "ready_to_expand": bool(launch_week_monitoring.get("ready_to_expand", False)), + "vercel_deployment_id": launch_week_monitoring.get("vercel_deployment_id"), + "breached_signals": list(launch_week_monitoring.get("breached_signals") or []), + }, + "blockers": blockers, + } + + +def _lane_a(base_dir: Path, *, require_remote_reader_quality: bool = False) -> Dict[str, Any]: + markdown_path = _first_existing_relative( + base_dir, + [ + "artifacts/lane_a_quality_closure_all_pack_500.md", + "artifacts/lane_a_1_13_all_pack_500.md", + "artifacts/lane_a_1_7_all_pack_500.md", + ], + ) + runtime_path = _first_existing_relative( + base_dir, + [ + "artifacts/lane_a_quality_closure_all_pack_500_runtime.json", + "artifacts/lane_a_1_13_all_pack_500_runtime.json", + "artifacts/lane_a_1_7_all_pack_500_runtime.json", + ], + ) + human_path = "artifacts/lane_a_1_8_500_human_sampling.json" + replay_path = "artifacts/lane_a_1_10_500_replay_result.json" + remote_quality_path = REMOTE_READER_QUALITY_SAMPLE_PATH + text = _read_text(base_dir, markdown_path) + human = _read_json(base_dir, human_path) + replay = _read_json(base_dir, replay_path) + remote_quality = _read_json(base_dir, remote_quality_path) + + packs_reaching = _line_list(r"- packs reaching target:\s*(.+)", text) + continue_worlds = _line_list(r"- continue worlds:\s*(.+)", text) + stop_ready_worlds = _line_list(r"- stop-ready worlds:\s*(.+)", text) + program_status_match = re.search(r"- program status:\s*([^\n]+)", text, flags=re.IGNORECASE) + program_status = program_status_match.group(1).strip() if program_status_match else None + hard_fail_count = _int_from_match(r"- hard fail count:\s*(\d+)", text) + scene_card_violations = _int_from_match(r"- scene-card visible text violations:\s*(\d+)", text) + human_target = int(human.get("target_count", 0) or 0) + human_reviewed = int(human.get("reviewed_count", 0) or 0) + human_high = int(human.get("high_count", 0) or 0) + human_medium = int(human.get("medium_count", 0) or 0) + human_ready = ( + human_target >= 36 + and human_reviewed >= human_target + and human_high == 0 + and human_medium <= int(human.get("max_medium", 0) or 0) + and bool(human.get("reader_q03_recovery_ready", False)) + and bool(human.get("reader_perceived_redundancy_closeout_ready", False)) + ) + replay_ready = ( + _accepted_status(replay.get("status")) + and int(replay.get("reviewed_count", 0) or 0) >= int(replay.get("target_count", 0) or 0) + and int(replay.get("worlds_reaching_500", 0) or 0) >= int(replay.get("world_count", 0) or 0) + and not list(replay.get("console_errors") or []) + ) + fresh_500_ready = ( + len(packs_reaching) >= 6 + and hard_fail_count == 0 + and scene_card_violations == 0 + and _artifact_path(base_dir, runtime_path).exists() + and program_status == "stop_ready" + and not [world for world in continue_worlds if world in {"jade_court_exam", "jade_court_romance"}] + ) + remote_quality_ready = ( + bool(remote_quality.get("ready", False)) + and _accepted_status(remote_quality.get("status")) + and int(remote_quality.get("violation_count", 0) or 0) == 0 + and not _payload_contains_secret(remote_quality) + and not _looks_local_url(remote_quality.get("public_app_url") or remote_quality.get("api_origin")) + ) + blockers: List[Dict[str, Any]] = [] + if len(packs_reaching) < 6: + blockers.append(_blocker("longform_500_all_pack_incomplete", "Fresh all-pack 500 evidence did not show 6/6 packs reaching 500.", packs_reaching=packs_reaching)) + if hard_fail_count != 0: + blockers.append(_blocker("longform_500_hard_failures_present", "Persisted chapter hard failures must be zero.", hard_fail_count=hard_fail_count)) + if scene_card_violations != 0: + blockers.append(_blocker("scene_card_visible_text_violations_present", "Scene-card visible text violations must be zero.", violation_count=scene_card_violations)) + if not human_ready: + blockers.append(_blocker("review_sample_coverage_500_human_closeout_not_ready", "36 chapter human sampling closeout is incomplete or has unacceptable risk.", target_count=human_target, reviewed_count=human_reviewed, high_count=human_high, medium_count=human_medium)) + if not replay_ready: + blockers.append(_blocker("reader_500_replay_not_ready", "Reader 500 replay projection evidence is missing or has console/sample failures.")) + if not _artifact_path(base_dir, runtime_path).exists(): + blockers.append(_blocker("longform_500_runtime_profile_missing", "Runtime profile artifact is required.")) + jade_continue = [world for world in continue_worlds if world in {"jade_court_exam", "jade_court_romance"}] + if program_status != "stop_ready" or jade_continue: + blockers.append(_blocker("weakest_pack_polish_program_not_stop_ready", "Jade weakest-pack continue_polish must be closed before promotion.", program_status=program_status, continue_worlds=continue_worlds)) + if require_remote_reader_quality and not remote_quality_ready: + blockers.append( + _blocker( + "remote_reader_quality_sample_not_ready", + "Formal-domain Reader generated sample must pass content-quality smoke before promotion.", + violation_count=remote_quality.get("violation_count"), + issue_counts=remote_quality.get("issue_counts"), + ) + ) + + evidence = { + "fresh_500_ready": fresh_500_ready, + "packs_reaching_500_count": len(packs_reaching), + "packs_reaching_500": packs_reaching, + "hard_fail_count": hard_fail_count, + "scene_card_visible_text_violation_count": scene_card_violations, + "human_sampling_ready": human_ready, + "review_sample_coverage_500": { + "human_closeout_ready": human_ready, + "target_count": human_target, + "reviewed_count": human_reviewed, + "high_count": human_high, + "medium_count": human_medium, + }, + "reader_500_replay_ready": replay_ready, + "runtime_profile_present": _artifact_path(base_dir, runtime_path).exists(), + "weakest_pack_polish_program": { + "status": program_status, + "stop_ready_worlds": stop_ready_worlds, + "continue_worlds": continue_worlds, + }, + "remote_reader_quality_sample_ready": remote_quality_ready, + "remote_reader_quality_sample": { + "status": remote_quality.get("status"), + "sample_node_count": remote_quality.get("sample_node_count"), + "violation_count": remote_quality.get("violation_count"), + "issue_counts": remote_quality.get("issue_counts"), + }, + "product_ready_band_500_promoted": not blockers, + } + return _lane_result( + lane="A", + task="1.13" if "lane_a_1_13" in markdown_path else "1.12", + title=( + "Jade continue_polish Kernel Closure" + if "lane_a_1_13" in markdown_path + else "500-Ready Promotion Closure" + ), + evidence=evidence, + blockers=blockers, + artifacts=[ + _artifact(base_dir, markdown_path), + _artifact(base_dir, runtime_path), + _artifact(base_dir, human_path), + _artifact(base_dir, replay_path), + _artifact(base_dir, remote_quality_path), + ], + recommended_actions=[] if not blockers else ["close_jade_continue_polish", "rerun_longform_500_with_human_closeout_ingested"], + ) + + +def _lane_c(base_dir: Path, *, remote_only: bool = False) -> Dict[str, Any]: + reader_path = "artifacts/reader_paid_path_smoke_result.json" + uat_path = "artifacts/commercialization_uat/latest/summary.json" + stripe_path = "artifacts/stripe_external_acceptance/latest/external_acceptance_summary.json" + reader = _read_json(base_dir, reader_path) + uat = _read_json(base_dir, uat_path) + stripe = _read_json(base_dir, stripe_path) + journey = dict(uat.get("journey") or {}) + lifecycle_after_failure = dict(journey.get("lifecycle_after_failure") or {}) + lifecycle_after_recovery = dict(journey.get("lifecycle_after_recovery") or {}) + + blockers: List[Dict[str, Any]] = [] + if not _accepted_status(reader.get("status")): + blockers.append(_blocker("reader_paid_path_smoke_not_passed", "Reader paid path smoke must pass.")) + if str(reader.get("subscription_status") or "") != "active": + blockers.append(_blocker("subscription_not_active_after_checkout", "Checkout completion must activate the subscription.")) + paywall = dict(reader.get("paywall") or {}) + if not bool(paywall.get("required")) or str(paywall.get("reason") or "") != "credits_exhausted": + blockers.append(_blocker("reader_paywall_payment_required_not_proven", "Unsubscribed/zero-credit Reader path must hit payment_required semantics.")) + if list(reader.get("apiErrors") or []) or list(reader.get("browserResponseErrors") or []) or list(reader.get("consoleErrors") or []): + blockers.append(_blocker("reader_paid_path_browser_or_api_errors", "Paid path smoke must have no browser/API errors.")) + if remote_only: + runtime = dict(reader.get("runtime") or {}) + if not bool(reader.get("remote_mode", False) or runtime.get("remote_mode", False)): + blockers.append(_blocker("reader_paid_path_not_remote", "Commercial ready billing evidence must come from the formal-domain Reader paid-path smoke.")) + if reader.get("schema_version") != "reader_paid_path_smoke/v2": + blockers.append(_blocker("reader_paid_path_schema_outdated", "Reader paid-path remote smoke must emit schema v2.")) + if reader.get("database_ref") or runtime.get("database_ref"): + blockers.append(_blocker("reader_paid_path_db_ref_present", "Remote Reader paid-path smoke must not depend on or record database refs.")) + acceptance = dict(uat.get("acceptance") or {}) + if not bool(acceptance.get("all_passed", False)): + blockers.append(_blocker("commercialization_uat_not_passed", "Commercial billing UAT acceptance must pass.")) + dunning_failure = dict(lifecycle_after_failure.get("dunning_summary") or {}) + dunning_recovery = dict(lifecycle_after_recovery.get("dunning_summary") or {}) + if str(dunning_failure.get("status") or "") != "open" or str(dunning_recovery.get("status") or "") != "resolved": + blockers.append(_blocker("failed_payment_retry_reconcile_not_visible", "Failure, retry, and recovery lifecycle evidence is required.")) + invoice = dict(stripe.get("invoice") or {}) + stripe_test_mode = bool(dict(invoice.get("invoice_payload_json") or {}).get("livemode") is False) if invoice else True + if not stripe_test_mode: + blockers.append(_blocker("live_payment_provider_detected", "Paid pilot evidence must not use live provider keys.")) + + evidence = { + "reader_paid_path_status": reader.get("status"), + "paywall_reason": paywall.get("reason"), + "checkout_session_id": reader.get("checkout_session_id"), + "subscription_status": reader.get("subscription_status"), + "effective_tier": reader.get("effective_tier"), + "story_credits_after_checkout": next((step.get("story_credits") for step in reader.get("steps", []) if step.get("name") == "verify_post_payment_state"), None), + "commercialization_uat_all_passed": bool(acceptance.get("all_passed", False)), + "commercialization_uat_checkpoint_count": acceptance.get("checkpoint_count"), + "failed_payment_status": dunning_failure.get("status"), + "recovered_payment_status": dunning_recovery.get("status"), + "stripe_external_acceptance_test_mode": stripe_test_mode, + "provider": paywall.get("provider"), + "remote_mode": bool(reader.get("remote_mode", False) or dict(reader.get("runtime") or {}).get("remote_mode", False)), + "smoke_run_id": reader.get("smoke_run_id"), + } + return _lane_result( + lane="C", + task="3.9", + title="Paid Pilot Billing / Credits / Audit E2E", + evidence=evidence, + blockers=blockers, + artifacts=[_artifact(base_dir, reader_path), _artifact(base_dir, uat_path), _artifact(base_dir, stripe_path)], + recommended_actions=[] if not blockers else ["rerun_reader_paid_path_smoke", "rerun_commercialization_uat"], + ) + + +def _lane_f(base_dir: Path, runtime_evidence: Dict[str, Any]) -> Dict[str, Any]: + blockers = list(runtime_evidence.get("blockers") or []) + evidence = { + "runtime_evidence_ready": bool(runtime_evidence.get("ready", False)), + "fixed_ports": runtime_evidence.get("fixed_ports"), + "checks": runtime_evidence.get("checks"), + "health": runtime_evidence.get("health"), + "latest_backup_manifest": runtime_evidence.get("latest_backup_manifest"), + "latest_restore_result": runtime_evidence.get("latest_restore_result"), + } + artifacts = [ + _artifact(base_dir, "docs/deployment_runbook.md"), + _artifact(base_dir, "scripts/run_backend_local.sh"), + ] + latest_backup = runtime_evidence.get("latest_backup_manifest") + latest_restore = runtime_evidence.get("latest_restore_result") + if latest_backup: + artifacts.append(_artifact(base_dir, str(latest_backup))) + if latest_restore: + artifacts.append(_artifact(base_dir, str(latest_restore))) + return _lane_result( + lane="F", + task="6.8", + title="Staging/Prod Runtime Evidence Pack", + evidence=evidence, + blockers=blockers, + artifacts=artifacts, + recommended_actions=[] if not blockers else ["restart_backend_on_8000", "rerun_runtime_preflight_evidence_builder"], + ) + + +def _lane_b(base_dir: Path) -> Dict[str, Any]: + result_path = "artifacts/author_repair_loop_smoke_result.json" + result = _read_json(base_dir, result_path) + summary = dict(result.get("summary") or {}) + completed_steps = list(result.get("completed_steps") or []) + blockers: List[Dict[str, Any]] = [] + if not _accepted_status(result.get("status")): + blockers.append(_blocker("author_repair_loop_smoke_not_passed", "Author repair loop smoke must pass.")) + required_steps = { + "author_create_draft_from_brief", + "author_simulate_draft", + "author_repair_loop_visible_after_rerun", + "author_execute_strategy_bundle", + "author_repair_loop_ready_for_validation", + "author_submit_repaired_draft", + } + missing_steps = sorted(required_steps - set(completed_steps)) + if missing_steps: + blockers.append(_blocker("author_repair_loop_missing_steps", "Author repair loop smoke did not cover required steps.", missing_steps=missing_steps)) + if not summary.get("author_repair_loop_issue_code"): + blockers.append(_blocker("author_repair_loop_issue_not_actionable", "Top issue code must be visible.")) + if not bool(summary.get("author_repair_loop_ready_for_validation", False)): + blockers.append(_blocker("author_repair_loop_not_ready_for_validation", "Ready-for-validation must drive submit for paid pilot acceptance.", severity="medium")) + if not summary.get("author_repair_loop_execution_id") or int(summary.get("author_repair_loop_applied_edit_count", 0) or 0) <= 0: + blockers.append(_blocker("author_strategy_bundle_receipt_missing", "Author repair loop must include an applied strategy bundle receipt.", severity="medium")) + if not bool(summary.get("author_repair_loop_before_after_available", False)): + blockers.append(_blocker("author_repair_loop_compare_missing", "Author repair loop must expose before/after compare evidence.", severity="medium")) + if str(summary.get("author_submit_status") or "") not in {"submitted", "review_requested", "pending_review"}: + blockers.append(_blocker("author_repair_loop_submit_missing", "Author repair loop must submit the repaired draft after validation.", severity="medium")) + evidence = { + "smoke_status": result.get("status"), + "completed_step_count": len(completed_steps), + "issue_code": summary.get("author_repair_loop_issue_code"), + "asset_type": summary.get("author_repair_loop_asset_type"), + "strategy_bundle_id": summary.get("author_repair_loop_strategy_bundle_id"), + "strategy_bundle_execution_id": summary.get("author_repair_loop_execution_id"), + "applied_edit_count": summary.get("author_repair_loop_applied_edit_count"), + "before_after_available": bool(summary.get("author_repair_loop_before_after_available", False)), + "severity_trend": summary.get("author_repair_loop_severity_trend"), + "ready_for_validation": bool(summary.get("author_repair_loop_ready_for_validation", False)), + "submit_status": summary.get("author_submit_status"), + "submit_review_request_id": summary.get("author_submit_review_request_id"), + "console_errors": list(result.get("console_errors") or []), + } + return _lane_result( + lane="B", + task="2.8", + title="Author Repair Workbench Efficiency", + evidence=evidence, + blockers=blockers, + artifacts=[_artifact(base_dir, result_path)], + recommended_actions=[] if not blockers else ["complete_author_strategy_bundle_submit_flow", "rerun_author_repair_loop_smoke"], + ) + + +def _lane_d(base_dir: Path) -> Dict[str, Any]: + ops_path = "artifacts/quantum_ops_url_state_smoke_result.json" + closure_path = "artifacts/human_signoff_closure/latest/operator_evidence_closure.json" + support_packet_path = "artifacts/human_signoff_closure/latest/support_review_packet.md" + ops = _read_json(base_dir, ops_path) + closure = _read_json(base_dir, closure_path) + completed_steps = list(ops.get("completed_steps") or []) + summary = dict(ops.get("summary") or {}) + blockers: List[Dict[str, Any]] = [] + if not _accepted_status(ops.get("status")): + blockers.append(_blocker("ops_url_state_smoke_not_passed", "Ops console smoke must pass.")) + required_steps = { + "open_quantum_ops_account", + "open_quantum_ops_release", + "open_quantum_ops_alerts", + "acknowledge_quantum_ops_alert", + "resolve_quantum_ops_alert", + "open_governance_case_detail", + "append_governance_evidence", + "transition_governance_status", + } + missing_steps = sorted(required_steps - set(completed_steps)) + if missing_steps: + blockers.append(_blocker("ops_console_missing_required_steps", "Ops console smoke did not cover required support/rollback steps.", missing_steps=missing_steps)) + if not summary.get("ops_account_account_id"): + blockers.append(_blocker("ops_account_workspace_not_proven", "Ops account workspace evidence is required.")) + signoff = dict(closure.get("signoff") or {}) + rollup = dict(signoff.get("rollup_summary_json") or {}) + pending_items = int(rollup.get("pending_item_count", 0) or 0) + if pending_items > 0: + blockers.append(_blocker("operator_evidence_closure_pending", "Operator support/rollback evidence still has pending items.", severity="medium", pending_item_count=pending_items)) + evidence = { + "ops_smoke_status": ops.get("status"), + "completed_step_count": len(completed_steps), + "ops_account_account_id": summary.get("ops_account_account_id"), + "ops_alert_id": summary.get("ops_alert_id"), + "ops_governance_case_id": summary.get("ops_governance_case_id"), + "ops_release_url_restored": summary.get("ops_release_url_restored"), + "operator_evidence_status": closure.get("status"), + "operator_pending_item_count": pending_items, + "support_packet_present": _artifact_path(base_dir, support_packet_path).exists(), + } + return _lane_result( + lane="D", + task="4.7", + title="Ops Support / Rollback Pilot Console", + evidence=evidence, + blockers=blockers, + artifacts=[_artifact(base_dir, ops_path), _artifact(base_dir, closure_path), _artifact(base_dir, support_packet_path)], + recommended_actions=[] if not blockers else ["close_operator_support_rollback_evidence", "rerun_ops_pilot_console_smoke"], + ) + + +def build_paid_pilot_acceptance( + *, + base_dir: Path, + health_url: str = "http://127.0.0.1:8000/health", + public_app_url: Optional[str] = None, + vercel_deployment_id: Optional[str] = None, + vercel_domain: Optional[str] = None, + remote_smoke_summary_path: Optional[str] = None, + reader_paid_path_summary_path: Optional[str] = None, + author_approval_summary_path: Optional[str] = None, + ops_remote_smoke_summary_path: Optional[str] = None, + vercel_performance_summary_path: Optional[str] = None, + npm_audit_summary_path: Optional[str] = None, + database_readiness_summary_path: Optional[str] = None, + database_load_smoke_summary_path: Optional[str] = None, + reader_quality_sample_summary_path: Optional[str] = None, + launch_week_monitoring_summary_path: Optional[str] = None, +) -> Dict[str, Any]: + remote_only = bool(public_app_url) + if remote_only: + remote_smoke_summary_path = remote_smoke_summary_path or REMOTE_PRODUCT_SMOKE_PATH + reader_paid_path_summary_path = reader_paid_path_summary_path or REMOTE_READER_PAID_PATH_SMOKE_PATH + author_approval_summary_path = author_approval_summary_path or REMOTE_AUTHOR_APPROVAL_SMOKE_PATH + ops_remote_smoke_summary_path = ops_remote_smoke_summary_path or REMOTE_OPS_SMOKE_PATH + vercel_performance_summary_path = vercel_performance_summary_path or REMOTE_VERCEL_PERFORMANCE_PATH + npm_audit_summary_path = npm_audit_summary_path or REMOTE_NPM_AUDIT_PATH + database_readiness_summary_path = database_readiness_summary_path or REMOTE_DATABASE_READINESS_PATH + database_load_smoke_summary_path = database_load_smoke_summary_path or REMOTE_DATABASE_LOAD_SMOKE_PATH + reader_quality_sample_summary_path = reader_quality_sample_summary_path or REMOTE_READER_QUALITY_SAMPLE_PATH + launch_week_monitoring_summary_path = launch_week_monitoring_summary_path or LAUNCH_WEEK_MONITORING_PATH + runtime_evidence = build_runtime_pilot_evidence(base_dir=base_dir, health_url=health_url, remote_only=remote_only) + remote_vercel_acceptance = build_remote_vercel_acceptance( + base_dir=base_dir, + public_app_url=public_app_url, + vercel_deployment_id=vercel_deployment_id, + vercel_domain=vercel_domain, + remote_smoke_summary_path=remote_smoke_summary_path, + reader_paid_path_summary_path=reader_paid_path_summary_path, + author_approval_summary_path=author_approval_summary_path, + ops_remote_smoke_summary_path=ops_remote_smoke_summary_path, + vercel_performance_summary_path=vercel_performance_summary_path, + npm_audit_summary_path=npm_audit_summary_path, + database_readiness_summary_path=database_readiness_summary_path, + database_load_smoke_summary_path=database_load_smoke_summary_path, + reader_quality_sample_summary_path=reader_quality_sample_summary_path, + launch_week_monitoring_summary_path=launch_week_monitoring_summary_path, + ) + lanes = [ + _lane_a(base_dir, require_remote_reader_quality=remote_only), + _lane_c(base_dir, remote_only=remote_only), + _lane_f(base_dir, runtime_evidence), + _lane_b(base_dir), + _lane_d(base_dir), + ] + lane_statuses = {lane["lane"]: lane["status"] for lane in lanes} + blockers = [ + { + "lane": lane["lane"], + "task": lane["task"], + "key": blocker.get("key"), + "severity": blocker.get("severity"), + "detail": blocker.get("detail"), + } + for lane in lanes + for blocker in lane.get("blockers", []) + ] + blockers.extend( + { + "lane": "F", + "task": "6.10" if str(blocker.get("key") or "").startswith("public_beta_database") else "6.9", + "key": blocker.get("key"), + "severity": blocker.get("severity"), + "detail": blocker.get("detail"), + } + for blocker in remote_vercel_acceptance.get("blockers", []) + ) + ready = all(lane["ready"] for lane in lanes) and bool(remote_vercel_acceptance.get("ready", True)) + return { + "schema_version": "paid_pilot_acceptance/v1", + "generated_at": _utc_now(), + "target": "controlled_paid_pilot", + "ready": ready, + "status": "passed" if ready else "blocked", + "lane_statuses": lane_statuses, + "lanes": lanes, + "blockers": blockers, + "acceptance_contract": { + "public_self_serve_launch": False, + "requires_live_stripe_keys": False, + "requires_redacted_live_payment_refs": True, + "invite_only_checkout_supported": True, + "executes_production_restore": False, + "commercial_ready_source": "formal_domain_remote" if remote_only else "local_dev", + "formal_domain": FORMAL_PILOT_DOMAIN if remote_only else None, + "local_manual_ports": {"frontend": 3000, "backend": 8000}, + }, + "runtime_pilot_evidence": runtime_evidence, + "remote_vercel_acceptance": remote_vercel_acceptance, + } + + +def render_lane_markdown(lane: Dict[str, Any]) -> str: + lines = [ + f"# Lane {lane.get('lane')} Task {lane.get('task')}: {lane.get('title')}", + "", + f"- status: {lane.get('status')}", + f"- ready: {'yes' if lane.get('ready') else 'no'}", + "", + "## Evidence", + ] + for key, value in dict(lane.get("evidence") or {}).items(): + lines.append(f"- {key}: {json.dumps(value, ensure_ascii=False, sort_keys=True)}") + lines.extend(["", "## Blockers"]) + blockers = list(lane.get("blockers") or []) + if blockers: + for blocker in blockers: + lines.append(f"- {blocker.get('key')}: {blocker.get('detail')}") + else: + lines.append("- none") + lines.extend(["", "## Artifacts"]) + for artifact in lane.get("artifacts", []): + lines.append(f"- {artifact.get('path')}: {'present' if artifact.get('exists') else 'missing'}") + lines.append("") + return "\n".join(lines) + + +def render_paid_pilot_markdown(summary: Dict[str, Any], *, customer_packet: bool = False) -> str: + title = "Paid Pilot Customer Signoff Packet" if customer_packet else "Paid Pilot Acceptance Report" + lines = [ + f"# {title}", + "", + f"- generated at: {summary.get('generated_at')}", + f"- target: {summary.get('target')}", + f"- status: {summary.get('status')}", + f"- ready: {'yes' if summary.get('ready') else 'no'}", + "- scope: controlled paid pilot, not public self-serve launch", + "- local payment execution: sandbox/test provider only; invite-only live cutover requires redacted operator refs and external live secrets", + "- production restore: request/approval evidence only, no automatic restore", + f"- public app url: {dict(summary.get('remote_vercel_acceptance') or {}).get('public_app_url') or 'not provided'}", + f"- vercel deployment id: {dict(summary.get('remote_vercel_acceptance') or {}).get('vercel_deployment_id') or 'not provided'}", + f"- commercial ready source: {dict(summary.get('acceptance_contract') or {}).get('commercial_ready_source') or 'not provided'}", + "", + "## Lane Status", + ] + for lane in summary.get("lanes", []): + lines.append(f"- Lane {lane.get('lane')} Task {lane.get('task')}: {lane.get('status')} ({lane.get('title')})") + lines.extend(["", "## Blocking Items"]) + blockers = list(summary.get("blockers") or []) + if blockers: + for blocker in blockers: + lines.append(f"- Lane {blocker.get('lane')} {blocker.get('key')}: {blocker.get('detail')}") + else: + lines.append("- none") + if not customer_packet: + remote = dict(summary.get("remote_vercel_acceptance") or {}) + lines.extend( + [ + "", + "## Remote Vercel Acceptance", + f"- status: {remote.get('status')}", + f"- public_app_url: {remote.get('public_app_url')}", + f"- vercel_domain: {remote.get('vercel_domain')}", + f"- vercel_deployment_id: {remote.get('vercel_deployment_id')}", + f"- remote_smoke: {json.dumps(remote.get('remote_smoke') or {}, ensure_ascii=False, sort_keys=True)}", + f"- reader_quality_sample: {json.dumps(remote.get('reader_quality_sample') or {}, ensure_ascii=False, sort_keys=True)}", + f"- reader_paid_path: {json.dumps(remote.get('reader_paid_path') or {}, ensure_ascii=False, sort_keys=True)}", + f"- author_approval: {json.dumps(remote.get('author_approval') or {}, ensure_ascii=False, sort_keys=True)}", + f"- ops_remote_smoke: {json.dumps(remote.get('ops_remote_smoke') or {}, ensure_ascii=False, sort_keys=True)}", + f"- performance: {json.dumps(remote.get('performance') or {}, ensure_ascii=False, sort_keys=True)}", + f"- npm_audit: {json.dumps(remote.get('npm_audit') or {}, ensure_ascii=False, sort_keys=True)}", + f"- database_readiness: {json.dumps(remote.get('database_readiness') or {}, ensure_ascii=False, sort_keys=True)}", + f"- database_load_smoke: {json.dumps(remote.get('database_load_smoke') or {}, ensure_ascii=False, sort_keys=True)}", + f"- launch_week_monitoring: {json.dumps(remote.get('launch_week_monitoring') or {}, ensure_ascii=False, sort_keys=True)}", + ] + ) + lines.extend(["", "## Evidence Details"]) + for lane in summary.get("lanes", []): + lines.append("") + lines.append(f"### Lane {lane.get('lane')}") + for key, value in dict(lane.get("evidence") or {}).items(): + lines.append(f"- {key}: {json.dumps(value, ensure_ascii=False, sort_keys=True)}") + lines.append("") + return "\n".join(lines) + + +def write_paid_pilot_acceptance( + *, + base_dir: Path, + output_dir: Optional[Path] = None, + health_url: str = "http://127.0.0.1:8000/health", + public_app_url: Optional[str] = None, + vercel_deployment_id: Optional[str] = None, + vercel_domain: Optional[str] = None, + remote_smoke_summary_path: Optional[str] = None, + reader_paid_path_summary_path: Optional[str] = None, + author_approval_summary_path: Optional[str] = None, + ops_remote_smoke_summary_path: Optional[str] = None, + vercel_performance_summary_path: Optional[str] = None, + npm_audit_summary_path: Optional[str] = None, + database_readiness_summary_path: Optional[str] = None, + database_load_smoke_summary_path: Optional[str] = None, + reader_quality_sample_summary_path: Optional[str] = None, + launch_week_monitoring_summary_path: Optional[str] = None, +) -> Dict[str, Any]: + output = output_dir or base_dir / "artifacts/paid_pilot_acceptance/latest" + output.mkdir(parents=True, exist_ok=True) + summary = build_paid_pilot_acceptance( + base_dir=base_dir, + health_url=health_url, + public_app_url=public_app_url, + vercel_deployment_id=vercel_deployment_id, + vercel_domain=vercel_domain, + remote_smoke_summary_path=remote_smoke_summary_path, + reader_paid_path_summary_path=reader_paid_path_summary_path, + author_approval_summary_path=author_approval_summary_path, + ops_remote_smoke_summary_path=ops_remote_smoke_summary_path, + vercel_performance_summary_path=vercel_performance_summary_path, + npm_audit_summary_path=npm_audit_summary_path, + database_readiness_summary_path=database_readiness_summary_path, + database_load_smoke_summary_path=database_load_smoke_summary_path, + reader_quality_sample_summary_path=reader_quality_sample_summary_path, + launch_week_monitoring_summary_path=launch_week_monitoring_summary_path, + ) + (output / "summary.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False, sort_keys=True), encoding="utf-8") + (output / "report.md").write_text(render_paid_pilot_markdown(summary), encoding="utf-8") + (output / "customer_signoff_packet.md").write_text( + render_paid_pilot_markdown(summary, customer_packet=True), + encoding="utf-8", + ) + + for lane in summary.get("lanes", []): + lane_code = str(lane.get("lane") or "") + lane_dir = base_dir / LANE_OUTPUT_DIRS.get(lane_code, Path("artifacts/paid_pilot_acceptance/latest")) + lane_dir.mkdir(parents=True, exist_ok=True) + (lane_dir / "summary.json").write_text(json.dumps(lane, indent=2, ensure_ascii=False, sort_keys=True), encoding="utf-8") + (lane_dir / "report.md").write_text(render_lane_markdown(lane), encoding="utf-8") + runtime_dir = base_dir / LANE_OUTPUT_DIRS["F"] + runtime_dir.mkdir(parents=True, exist_ok=True) + runtime = dict(summary.get("runtime_pilot_evidence") or {}) + (runtime_dir / "runtime_evidence.json").write_text(json.dumps(runtime, indent=2, ensure_ascii=False, sort_keys=True), encoding="utf-8") + return summary diff --git a/src/narrativeos/services/partner_readiness.py b/src/narrativeos/services/partner_readiness.py new file mode 100644 index 0000000..94e2498 --- /dev/null +++ b/src/narrativeos/services/partner_readiness.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from collections import Counter +from typing import Any, Dict, List, Optional + +from ..persistence.repositories import SQLAlchemyPlatformRepository +from .commercial_audit import CommercialAuditService + + +PARTNER_LIFECYCLE_STATUSES = { + "discovered", + "parsed", + "verified", + "commercial_qualified", + "active", + "paused", + "blocked", +} + + +class PartnerReadinessService: + def __init__(self, repository: SQLAlchemyPlatformRepository, *, audit_service: CommercialAuditService) -> None: + self.repository = repository + self.audit = audit_service + + def _validate_lifecycle(self, status: str) -> str: + normalized = str(status or "").strip() + if normalized not in PARTNER_LIFECYCLE_STATUSES: + raise ValueError("partner_lifecycle_invalid:%s" % normalized) + return normalized + + def upsert_partner( + self, + payload: Dict[str, Any], + ) -> Dict[str, Any]: + partner = self.repository.save_partner( + { + "partner_id": payload.get("partner_id"), + "name": payload["name"], + "lifecycle_status": self._validate_lifecycle(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": list(payload.get("allowlisted_channels") or []), + "primary_endpoint_url": payload.get("primary_endpoint_url"), + "endpoint_health_status": payload.get("endpoint_health_status", "unknown"), + "partner_payload": dict(payload.get("partner_payload") or {}), + } + ) + capabilities = self.repository.replace_partner_capabilities( + partner_id=partner["partner_id"], + capabilities=list(payload.get("capabilities") or []), + ) + if payload.get("health_check"): + self.repository.save_partner_health_check( + { + "partner_id": partner["partner_id"], + **dict(payload.get("health_check") or {}), + } + ) + detail = self.partner_detail(partner["partner_id"]) + self.audit.record_audit_log( + actor_id="ops_seed", + actor_role="ops", + account_id=None, + object_type="partner", + object_id=partner["partner_id"], + action_type="partner_upserted", + source_surface="ops", + customer_visible_payload={"partner": detail.get("partner")}, + internal_payload=detail, + ) + return detail + + def change_status(self, *, partner_id: str, status: str, note: Optional[str] = None) -> Dict[str, Any]: + existing = self.repository.get_partner(partner_id) + updated = self.repository.save_partner( + { + **existing, + "lifecycle_status": self._validate_lifecycle(status), + "allowlisted_channels_json": existing.get("allowlisted_channels_json", []), + "partner_payload_json": { + **dict(existing.get("partner_payload_json") or {}), + "status_note": note, + }, + } + ) + detail = self.partner_detail(updated["partner_id"]) + self.audit.record_audit_log( + actor_id="ops_status", + actor_role="ops", + account_id=None, + object_type="partner", + object_id=updated["partner_id"], + action_type="partner_status_changed", + source_surface="ops", + customer_visible_payload={"partner": detail.get("partner")}, + internal_payload={**detail, "note": note}, + ) + return detail + + def record_health_check( + self, + *, + partner_id: str, + endpoint_url: Optional[str], + status: str, + status_code: Optional[int] = None, + response_time_ms: Optional[float] = None, + health_payload: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + check = self.repository.save_partner_health_check( + { + "partner_id": partner_id, + "endpoint_url": endpoint_url, + "status": status, + "status_code": status_code, + "response_time_ms": response_time_ms, + "health_payload": dict(health_payload or {}), + } + ) + partner = self.repository.get_partner(partner_id) + self.repository.save_partner( + { + **partner, + "allowlisted_channels_json": partner.get("allowlisted_channels_json", []), + "endpoint_health_status": status, + "partner_payload_json": dict(partner.get("partner_payload_json") or {}), + } + ) + return check + + def _readiness_summary(self, *, partner: Dict[str, Any], capabilities: List[Dict[str, Any]], health_checks: List[Dict[str, Any]]) -> Dict[str, Any]: + capability_counts = Counter(str(item.get("status") or "unknown") for item in capabilities) + latest_health = health_checks[0] if health_checks else None + return { + "lifecycle_status": partner.get("lifecycle_status"), + "allowlisted_channel_count": len(partner.get("allowlisted_channels_json") or []), + "capability_status_counts": dict(capability_counts), + "latest_health_status": (latest_health or {}).get("status") or partner.get("endpoint_health_status"), + "receipt_capability": partner.get("receipt_capability"), + "disclosure_readiness": partner.get("disclosure_readiness"), + "billing_readiness": partner.get("billing_readiness"), + "commercial_ready": ( + str(partner.get("lifecycle_status") or "") in {"commercial_qualified", "active"} + and str(partner.get("disclosure_readiness") or "") in {"ready", "verified"} + and str(partner.get("billing_readiness") or "") in {"ready", "verified"} + and str((latest_health or {}).get("status") or partner.get("endpoint_health_status") or "") in {"healthy", "pass"} + ), + } + + def partner_detail(self, partner_id: str) -> Dict[str, Any]: + partner = self.repository.get_partner(partner_id) + capabilities = self.repository.list_partner_capabilities(partner_id=partner_id) + health_checks = self.repository.list_partner_health_checks(partner_id=partner_id, limit=10) + return { + "partner": partner, + "capabilities": capabilities, + "health_checks": health_checks, + "readiness_summary": self._readiness_summary(partner=partner, capabilities=capabilities, health_checks=health_checks), + } + + def list_partners(self, *, lifecycle_status: Optional[str] = None, limit: int = 50) -> Dict[str, Any]: + partners = self.repository.list_partners(lifecycle_status=lifecycle_status, limit=limit) + details = [self.partner_detail(item["partner_id"]) for item in partners] + return { + "partners": details, + "summary": { + "partner_count": len(details), + "lifecycle_counts": dict(Counter(str(item["partner"].get("lifecycle_status") or "unknown") for item in details)), + "health_counts": dict(Counter(str(item["readiness_summary"].get("latest_health_status") or "unknown") for item in details)), + }, + } diff --git a/src/narrativeos/services/production_acceptance.py b/src/narrativeos/services/production_acceptance.py new file mode 100644 index 0000000..81b926f --- /dev/null +++ b/src/narrativeos/services/production_acceptance.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from .commercial_audit import CommercialAuditService +from .customer_workspace import CustomerWorkspaceService +from .production_signoff import ProductionSignoffService +from ..persistence.repositories import SQLAlchemyPlatformRepository + + +class ProductionAcceptanceService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + customer_workspace_service: CustomerWorkspaceService, + production_signoff_service: ProductionSignoffService, + audit_service: CommercialAuditService, + ) -> None: + self.repository = repository + self.customer_workspace = customer_workspace_service + self.production_signoff = production_signoff_service + self.audit = audit_service + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _current_signoff(self, signoff_id: Optional[str] = None) -> Optional[Dict[str, Any]]: + if signoff_id: + try: + return self.production_signoff.signoff_detail(signoff_id=signoff_id) + except KeyError: + return None + current = self.production_signoff.list_signoffs(limit=1).get("current_signoff") + if not current: + return None + return self.production_signoff.signoff_detail(signoff_id=current["signoff_id"]) + + def _section(self, *, status: str, summary: str, blockers: Optional[List[str]] = None, refs: Optional[List[str]] = None) -> Dict[str, Any]: + return { + "status": status, + "summary": summary, + "blockers": list(blockers or []), + "refs": list(refs or []), + } + + def _workspace_readiness(self, payload: Dict[str, Any], *, signoff_detail: Optional[Dict[str, Any]]) -> Dict[str, Any]: + customer = dict(payload.get("customer_account") or {}) + billing_profile = dict(payload.get("billing_profile") or {}) + invoice_preview = dict(payload.get("invoice_preview") or {}) + campaign_summary = dict(payload.get("campaign_summary") or {}) + partner_perf = dict(payload.get("channel_partner_performance") or {}) + receipt_summary = dict(payload.get("receipt_summary") or {}) + handoff_summary = dict(payload.get("handoff_conversion_summary") or {}) + support_summary = dict(payload.get("support_summary") or {}) + dispute_summary = dict(payload.get("dispute_summary") or {}) + signoff = dict((signoff_detail or {}).get("signoff") or {}) + + billing_blockers: List[str] = [] + if not billing_profile.get("billing_profile_id"): + billing_blockers.append("billing_profile_missing") + if invoice_preview.get("total_due_usd") is None: + billing_blockers.append("invoice_preview_missing") + billing_status = "ready" if not billing_blockers else "blocked" + + campaign_counts = dict(campaign_summary.get("status_counts") or {}) + campaign_blockers: List[str] = [] + if int(campaign_summary.get("campaign_count") or 0) <= 0: + campaign_blockers.append("campaign_missing") + elif int(campaign_counts.get("active") or 0) <= 0 and int(campaign_counts.get("approved") or 0) <= 0: + campaign_blockers.append("campaign_not_approved") + campaign_status = "ready" if not campaign_blockers else ("warning" if campaign_counts else "blocked") + + partner_blockers: List[str] = [] + if not list(partner_perf.get("allowlisted_channels") or []): + partner_blockers.append("allowlisted_channel_missing") + partner_status = "ready" if not partner_blockers else "blocked" + + receipt_blockers: List[str] = [] + if int(receipt_summary.get("receipt_count") or 0) <= 0 and int(handoff_summary.get("validated_handoff_count") or 0) <= 0: + receipt_blockers.append("receipt_signal_missing") + receipt_status = "ready" if not receipt_blockers else "blocked" + + support_issues = int(support_summary.get("status_counts", {}).get("open") or 0) + int(support_summary.get("status_counts", {}).get("in_progress") or 0) + dispute_issues = int(dispute_summary.get("status_counts", {}).get("open") or 0) + int(dispute_summary.get("status_counts", {}).get("under_review") or 0) + int(dispute_summary.get("status_counts", {}).get("approved") or 0) + support_status = "warning" if (support_issues or dispute_issues) else "ready" + + signoff_status = str(signoff.get("status") or "missing") + signoff_blockers = [] if signoff_status == "fully_signed" else ["production_signoff_not_fully_signed"] + signoff_section_status = "ready" if not signoff_blockers else "blocked" + + sections = { + "billing": self._section( + status=billing_status, + summary=f"billing_profile {billing_profile.get('provider') or '-'} · invoice_due {invoice_preview.get('total_due_usd') or 0}", + blockers=billing_blockers, + refs=[billing_profile.get("billing_profile_id"), invoice_preview.get("invoice_preview_id")], + ), + "campaign": self._section( + status=campaign_status, + summary=f"campaign_count {campaign_summary.get('campaign_count') or 0} · active {campaign_counts.get('active') or 0} · approved {campaign_counts.get('approved') or 0}", + blockers=campaign_blockers, + refs=[customer.get("account_id")], + ), + "partner": self._section( + status=partner_status, + summary=f"allowlisted_channels {len(partner_perf.get('allowlisted_channels') or [])}", + blockers=partner_blockers, + refs=list(partner_perf.get("allowlisted_channels") or []), + ), + "receipt": self._section( + status=receipt_status, + summary=f"receipt_count {receipt_summary.get('receipt_count') or 0} · handoff {handoff_summary.get('validated_handoff_count') or 0} · conversion {handoff_summary.get('validated_conversion_count') or 0}", + blockers=receipt_blockers, + refs=[item.get("trace_id") for item in list(payload.get("linked_traces") or [])[:5] if item.get("trace_id")], + ), + "support": self._section( + status=support_status, + summary=f"support_open {support_issues} · dispute_open {dispute_issues}", + blockers=[], + refs=[customer.get("account_id")], + ), + "signoff": self._section( + status=signoff_section_status, + summary=f"production_signoff {signoff_status}", + blockers=signoff_blockers, + refs=[signoff.get("signoff_id")], + ), + } + blocked_sections = [key for key, value in sections.items() if value["status"] == "blocked"] + warning_sections = [key for key, value in sections.items() if value["status"] == "warning"] + overall_status = "ready" + if blocked_sections: + overall_status = "blocked" + elif warning_sections: + overall_status = "candidate" + return { + "overall_status": overall_status, + "blocked_sections": blocked_sections, + "warning_sections": warning_sections, + "sections": sections, + } + + def _sync_launch_wave(self, *, launch_wave: str) -> Dict[str, Any]: + ready_accounts = self.repository.list_go_live_ready_accounts(launch_wave=launch_wave, limit=500) + existing = next(iter(self.repository.list_launch_wave_statuses(launch_wave=launch_wave, limit=1)), None) + status_counts: Dict[str, int] = {} + account_ids: List[str] = [] + for item in ready_accounts: + status = str(item.get("status") or "unknown") + status_counts[status] = status_counts.get(status, 0) + 1 + if item.get("account_id"): + account_ids.append(str(item["account_id"])) + current_status = "planned" + existing_status = str((existing or {}).get("status") or "") + if existing_status in {"armed", "active", "rollback_watch"}: + current_status = existing_status + if status_counts.get("launched"): + current_status = "active" + elif status_counts.get("blocked") and existing_status not in {"armed", "active", "rollback_watch"}: + current_status = "blocked" + elif status_counts.get("ready") and existing_status not in {"armed", "active", "rollback_watch"}: + current_status = "planned" + return self.repository.save_launch_wave_status( + { + "launch_wave_status_id": (existing or {}).get("launch_wave_status_id"), + "launch_wave": launch_wave, + "status": current_status, + "target_environment": "production", + "wave_payload": { + "account_count": len(ready_accounts), + "status_counts": status_counts, + "account_ids": account_ids[:25], + }, + } + ) + + def generate_acceptance_record( + self, + *, + actor_id: str, + actor_role: str, + account_id: str, + launch_wave: str = "wave_1", + signoff_id: Optional[str] = None, + ) -> Dict[str, Any]: + workspace = self.customer_workspace.workspace(account_id=account_id) + customer = dict(workspace.get("customer_account") or {}) + signoff_detail = self._current_signoff(signoff_id=signoff_id) + readiness = self._workspace_readiness(workspace, signoff_detail=signoff_detail) + record = self.repository.save_production_customer_acceptance_record( + { + "customer_account_id": customer["customer_account_id"], + "account_id": account_id, + "signoff_id": (signoff_detail or {}).get("signoff", {}).get("signoff_id"), + "launch_wave": launch_wave, + "status": readiness["overall_status"], + "readiness_summary": readiness, + "acceptance_payload": { + "customer_account": customer, + "plan": workspace.get("plan"), + "billing_profile": workspace.get("billing_profile"), + "campaign_summary": workspace.get("campaign_summary"), + "channel_partner_performance": workspace.get("channel_partner_performance"), + "receipt_summary": workspace.get("receipt_summary"), + "handoff_conversion_summary": workspace.get("handoff_conversion_summary"), + "support_summary": workspace.get("support_summary"), + "dispute_summary": workspace.get("dispute_summary"), + }, + } + ) + ready_status = "ready" if readiness["overall_status"] == "ready" else ("blocked" if readiness["overall_status"] == "blocked" else "candidate") + ready_account = self.repository.save_go_live_ready_account( + { + "customer_account_id": customer["customer_account_id"], + "account_id": account_id, + "acceptance_record_id": record["acceptance_record_id"], + "launch_wave": launch_wave, + "status": ready_status, + "readiness_payload": readiness, + } + ) + wave_status = self._sync_launch_wave(launch_wave=launch_wave) + result = { + "acceptance_record": record, + "go_live_ready_account": ready_account, + "launch_wave_status": wave_status, + } + self.audit.record_audit_log( + actor_id=actor_id, + actor_role=actor_role, + account_id=account_id, + object_type="production_customer_acceptance", + object_id=record["acceptance_record_id"], + action_type="production_acceptance_generated", + source_surface="ops", + customer_visible_payload={}, + internal_payload=result, + ) + return result + + def acceptance_record_detail(self, *, acceptance_record_id: str) -> Dict[str, Any]: + record = self.repository.get_production_customer_acceptance_record(acceptance_record_id) + ready = next( + (item for item in self.repository.list_go_live_ready_accounts(account_id=record["account_id"], launch_wave=record["launch_wave"], limit=50) if item.get("acceptance_record_id") == acceptance_record_id), + None, + ) + launch_wave_status = next( + iter(self.repository.list_launch_wave_statuses(launch_wave=record["launch_wave"], limit=1)), + None, + ) + signoff = None + if record.get("signoff_id"): + try: + signoff = self.production_signoff.signoff_detail(signoff_id=record["signoff_id"]) + except KeyError: + signoff = None + return { + "acceptance_record": record, + "go_live_ready_account": ready, + "launch_wave_status": launch_wave_status, + "production_signoff": signoff, + } + + def list_acceptance_records( + self, + *, + launch_wave: Optional[str] = None, + status: Optional[str] = None, + limit: int = 50, + ) -> Dict[str, Any]: + records = self.repository.list_production_customer_acceptance_records(launch_wave=launch_wave, status=status, limit=limit) + waves = self.repository.list_launch_wave_statuses(limit=25) + ready_accounts = self.repository.list_go_live_ready_accounts(launch_wave=launch_wave, status=status if status in {"ready", "candidate", "blocked", "launched"} else None, limit=100) + return { + "acceptance_records": records, + "go_live_ready_accounts": ready_accounts, + "launch_waves": waves, + "summary": { + "acceptance_record_count": len(records), + "go_live_ready_count": len([item for item in ready_accounts if str(item.get("status") or "") == "ready"]), + "blocked_go_live_count": len([item for item in ready_accounts if str(item.get("status") or "") == "blocked"]), + "launch_wave_count": len(waves), + }, + } + + def update_launch_wave_status( + self, + *, + actor_id: str, + actor_role: str, + launch_wave: str, + status: str, + note: Optional[str] = None, + ) -> Dict[str, Any]: + existing = next(iter(self.repository.list_launch_wave_statuses(launch_wave=launch_wave, limit=1)), None) + current_payload = dict((existing or {}).get("wave_payload_json") or {}) + current_payload["note"] = note + row = self.repository.save_launch_wave_status( + { + "launch_wave_status_id": (existing or {}).get("launch_wave_status_id"), + "launch_wave": launch_wave, + "status": status, + "target_environment": (existing or {}).get("target_environment") or "production", + "wave_payload": current_payload, + } + ) + self.audit.record_audit_log( + actor_id=actor_id, + actor_role=actor_role, + account_id=None, + object_type="launch_wave_status", + object_id=row["launch_wave_status_id"], + action_type="launch_wave_status_updated", + source_surface="ops", + customer_visible_payload={}, + internal_payload={"launch_wave": launch_wave, "status": status, "note": note}, + ) + return {"launch_wave_status": row} diff --git a/src/narrativeos/services/production_cutover_checks.py b/src/narrativeos/services/production_cutover_checks.py new file mode 100644 index 0000000..ba1a083 --- /dev/null +++ b/src/narrativeos/services/production_cutover_checks.py @@ -0,0 +1,394 @@ +from __future__ import annotations + +import json +import os +import shutil +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict + +from fastapi.testclient import TestClient + +from ..repository import SQLAlchemyRepository +from .commercialization_uat import run_commercialization_uat + + +ROOT = Path(__file__).resolve().parents[3] + + +def _utcnow() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _register_identity(client: TestClient, *, actor_id: str, actor_role: str) -> str: + client.post( + "/v1/auth/register", + json={ + "actor_id": actor_id, + "actor_role": actor_role, + "password": "secret123", + "account_id": actor_id, + "display_name": actor_id, + }, + ) + login = client.post("/v1/auth/login", json={"actor_id": actor_id, "password": "secret123"}) + if login.status_code != 200: + raise RuntimeError(f"identity_login_failed:{actor_id}:{login.status_code}") + return login.json()["token"]["access_token"] + + +def _load_env_file(path: Path) -> None: + if not path.exists(): + return + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +def _load_default_env() -> None: + _load_env_file(ROOT / ".env.local") + _load_env_file(ROOT / ".env") + + +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 run_stripe_connectivity_check(*, output_root: str | Path | None = None) -> Dict[str, Any]: + _load_default_env() + price_map = {} + try: + price_map = json.loads(str(os.getenv("NARRATIVEOS_STRIPE_PRICE_MAP_JSON", "") or "{}")) + except json.JSONDecodeError: + price_map = {} + secret_key = str(os.getenv("NARRATIVEOS_STRIPE_SECRET_KEY", "")).strip() + publishable_key = str(os.getenv("NARRATIVEOS_STRIPE_PUBLISHABLE_KEY", "")).strip() + webhook_secret = str(os.getenv("NARRATIVEOS_STRIPE_WEBHOOK_SECRET", "")).strip() + provider = str(os.getenv("NARRATIVEOS_BILLING_PROVIDER", "")).strip() + stub_provider = provider in {"web_stub", "stub", "fake"} + stripe_configured = bool( + provider == "stripe" + and secret_key + and publishable_key + and webhook_secret + and isinstance(price_map, dict) + and all(str(price_map.get(key, "")).strip() for key in ("play_pass", "creator_pass", "studio_pass")) + ) + payload = { + "generated_at": _utcnow(), + "provider": provider, + "mode": ( + "stub" + if stub_provider + else ("live" if secret_key.startswith("sk_live_") else ("test" if secret_key.startswith("sk_test_") else "unknown")) + ), + "configured": bool(stripe_configured or stub_provider), + "checks": { + "provider_is_stripe": provider == "stripe", + "provider_is_stub": stub_provider, + "secret_key_present": bool(secret_key), + "publishable_key_present": bool(publishable_key), + "webhook_secret_present": bool(webhook_secret), + "price_map_has_required_tiers": all(str(price_map.get(key, "")).strip() for key in ("play_pass", "creator_pass", "studio_pass")), + }, + } + payload["healthy"] = bool(stub_provider or stripe_configured) + if output_root: + _write_json(Path(output_root) / "stripe_connectivity_check.json", payload) + return payload + + +def run_webhook_health_check(*, output_root: str | Path | None = None) -> Dict[str, Any]: + _load_default_env() + from ..api import create_app + + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "webhook_health.db" + app = create_app(repository=SQLAlchemyRepository(database_url=f"sqlite:///{db_path}")) + client = TestClient(app) + response = client.post("/v1/billing/stripe/webhook", content=b"{}", headers={"Stripe-Signature": "invalid"}) + payload = { + "generated_at": _utcnow(), + "status_code": response.status_code, + "response": response.json(), + "configured_secret_present": bool(str(os.getenv("NARRATIVEOS_STRIPE_WEBHOOK_SECRET", "")).strip()), + "healthy": response.status_code in {400, 503}, + } + if output_root: + _write_json(Path(output_root) / "webhook_health_check.json", payload) + return payload + + +def run_invoice_issuance_smoke(*, output_root: str | Path | None = None) -> Dict[str, Any]: + with tempfile.TemporaryDirectory() as temp_dir: + uat_summary = run_commercialization_uat(Path(temp_dir) / "commercialization_uat_smoke") + journey = dict(uat_summary.get("journey") or {}) + payload = { + "generated_at": _utcnow(), + "invoice_preview": journey.get("invoice_preview", {}), + "issued_invoice": journey.get("issued_invoice", {}), + "healthy": bool((journey.get("issued_invoice") or {}).get("hosted_invoice_url")), + } + if output_root: + _write_json(Path(output_root) / "invoice_issuance_smoke.json", payload) + return payload + + +def run_payment_sync_smoke(*, output_root: str | Path | None = None) -> Dict[str, Any]: + with tempfile.TemporaryDirectory() as temp_dir: + uat_summary = run_commercialization_uat(Path(temp_dir) / "commercialization_payment_smoke") + journey = dict(uat_summary.get("journey") or {}) + payload = { + "generated_at": _utcnow(), + "failed_invoice": journey.get("failed_invoice", {}), + "paid_invoice": journey.get("paid_invoice", {}), + "lifecycle_after_failure": journey.get("lifecycle_after_failure", {}), + "lifecycle_after_recovery": journey.get("lifecycle_after_recovery", {}), + "healthy": bool( + (journey.get("failed_invoice") or {}).get("status") == "failed" + and (journey.get("paid_invoice") or {}).get("status") == "paid" + and (journey.get("lifecycle_after_recovery") or {}).get("dunning_summary", {}).get("status") == "resolved" + ), + } + if output_root: + _write_json(Path(output_root) / "payment_sync_smoke.json", payload) + return payload + + +def run_customer_workspace_smoke(*, output_root: str | Path | None = None) -> Dict[str, Any]: + with tempfile.TemporaryDirectory() as temp_dir: + uat_summary = run_commercialization_uat(Path(temp_dir) / "commercialization_workspace_smoke") + journey = dict(uat_summary.get("journey") or {}) + failure_workspace = dict(journey.get("lifecycle_after_failure") or {}) + payload = { + "generated_at": _utcnow(), + "renewal_summary": failure_workspace.get("renewal_summary", {}), + "dunning_summary": failure_workspace.get("dunning_summary", {}), + "expansion_summary": failure_workspace.get("expansion_summary", {}), + "healthy": bool( + failure_workspace.get("renewal_summary", {}).get("status") == "renewal_due" + and failure_workspace.get("dunning_summary", {}).get("status") == "open" + and failure_workspace.get("expansion_summary", {}).get("recommended_plan_id") + ), + } + if output_root: + _write_json(Path(output_root) / "customer_workspace_smoke.json", payload) + return payload + + +def run_backup_restore_verification_hooks(*, output_root: str | Path | None = None) -> Dict[str, Any]: + from ..api import create_app + + with tempfile.TemporaryDirectory() as temp_dir: + target_root = Path(temp_dir) + db_path = target_root / "backup_restore_hooks.db" + backup_dir = target_root / "backups" + app = create_app(repository=SQLAlchemyRepository(database_url=f"sqlite:///{db_path}")) + client = TestClient(app) + reviewer_token = _register_identity(client, actor_id="ops_cutover_hooks", actor_role="reviewer") + admin_token = _register_identity(client, actor_id="ops_cutover_admin", actor_role="admin") + headers = {"Authorization": f"Bearer {reviewer_token}"} + admin_headers = {"Authorization": f"Bearer {admin_token}"} + + health = client.get("/health") + schema = client.get("/v1/ops/schema-lifecycle", headers=headers) + integrity = client.get("/v1/ops/data-integrity", headers=headers) + backup = client.post("/v1/ops/runtime-backups", headers=headers, json={"label": "cutover_smoke", "output_dir": str(backup_dir)}) + backup.raise_for_status() + backup_path = backup.json()["backup"]["backup_path"] + drill = client.post("/v1/ops/recovery-drill", headers=headers, json={"backup_path": backup_path}) + restore = client.post("/v1/ops/runtime-restore", headers=admin_headers, json={"backup_path": backup_path, "dry_run": True}) + payload = { + "generated_at": _utcnow(), + "health_status_code": health.status_code, + "schema_lifecycle": schema.json(), + "data_integrity": integrity.json(), + "backup": backup.json(), + "recovery_drill": drill.json(), + "restore": restore.json(), + "healthy": all( + [ + health.status_code == 200, + schema.status_code == 200, + integrity.status_code == 200, + backup.status_code == 200, + drill.status_code == 200, + restore.status_code == 200, + ] + ), + } + if output_root: + _write_json(Path(output_root) / "backup_restore_verification_hooks.json", payload) + return payload + + +def run_support_routing_smoke(*, output_root: str | Path | None = None) -> Dict[str, Any]: + from ..api import create_app + + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "support_routing_smoke.db" + app = create_app(repository=SQLAlchemyRepository(database_url=f"sqlite:///{db_path}")) + app.state.customer_account_service.ensure_customer_account( + account_id="acct_support_smoke", + display_name="Support Smoke", + plan_id="creator_pass", + status="active", + ) + case = app.state.commercial_support_service.create_support_case( + account_id="acct_support_smoke", + requested_by="acct_support_smoke", + payload={"subject": "support smoke", "description": "support route works", "priority": "high"}, + ) + listing = app.state.commercial_support_service.list_support_cases(account_id="acct_support_smoke", limit=10) + payload = { + "generated_at": _utcnow(), + "support_case": case, + "listing_summary": listing.get("summary") or {}, + "healthy": bool(case.get("support_case_id")) and int((listing.get("summary") or {}).get("case_count") or 0) >= 1, + } + if output_root: + _write_json(Path(output_root) / "support_routing_smoke.json", payload) + return payload + + +def run_audit_logging_smoke(*, output_root: str | Path | None = None) -> Dict[str, Any]: + from ..api import create_app + + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "audit_logging_smoke.db" + app = create_app(repository=SQLAlchemyRepository(database_url=f"sqlite:///{db_path}")) + app.state.customer_account_service.ensure_customer_account( + account_id="acct_audit_smoke", + display_name="Audit Smoke", + plan_id="creator_pass", + status="active", + ) + log = app.state.commercial_audit_service.record_audit_log( + actor_id="ops_audit_smoke", + actor_role="reviewer", + account_id="acct_audit_smoke", + object_type="audit_smoke", + object_id="audit_smoke_1", + action_type="audit_logging_smoke_created", + source_surface="ops", + customer_visible_payload={"ok": True}, + internal_payload={"smoke": True}, + ) + listing = app.state.commercial_audit_service.audit_log_listing(account_id="acct_audit_smoke", limit=10) + payload = { + "generated_at": _utcnow(), + "audit_log": log, + "listing_summary": listing.get("summary") or {}, + "healthy": bool(log.get("audit_log_id")) and int((listing.get("summary") or {}).get("entry_count") or 0) >= 1, + } + if output_root: + _write_json(Path(output_root) / "audit_logging_smoke.json", payload) + return payload + + +def build_production_cutover_pack(output_root: str | Path | None = None, *, base_dir: Optional[str | Path] = None) -> Dict[str, Any]: + run_id = f"production_cutover_pack_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}" + resolved_base_dir = Path(base_dir) if base_dir is not None else ROOT + bundle_dir = Path(output_root) if output_root else (resolved_base_dir / "artifacts" / "production_cutover_pack" / run_id) + bundle_dir.mkdir(parents=True, exist_ok=True) + + checks_dir = bundle_dir / "checks" + docs_dir = bundle_dir / "docs" + docs_dir.mkdir(parents=True, exist_ok=True) + + connectivity = run_stripe_connectivity_check(output_root=checks_dir) + webhook = run_webhook_health_check(output_root=checks_dir) + invoice = run_invoice_issuance_smoke(output_root=checks_dir) + payment = run_payment_sync_smoke(output_root=checks_dir) + customer_workspace = run_customer_workspace_smoke(output_root=checks_dir) + backup_restore = run_backup_restore_verification_hooks(output_root=checks_dir) + + doc_files = [ + "cutover_runbook.md", + "rollback_runbook.md", + "webhook_replay_runbook.md", + "billing_reconciliation_runbook.md", + "customer_launch_checklist.md", + ] + for name in doc_files: + shutil.copy2(resolved_base_dir / "docs" / name, docs_dir / name) + launch_week_docs_dir = resolved_base_dir / "artifacts" / "production_launch_week_pack" / "latest" / "docs" + if launch_week_docs_dir.exists(): + for path in sorted(launch_week_docs_dir.glob("*.md")): + shutil.copy2(path, docs_dir / path.name) + handshake_docs_dir = resolved_base_dir / "artifacts" / "production_handshake_pack" / "latest" / "docs" + if handshake_docs_dir.exists(): + for path in sorted(handshake_docs_dir.glob("*.md")): + shutil.copy2(path, docs_dir / path.name) + + signoff_evidence_map = { + "billing_005": ["checks/stripe_connectivity_check.json", "checks/invoice_issuance_smoke.json"], + "webhook_001": ["checks/webhook_health_check.json", "checks/payment_sync_smoke.json"], + "deploy_002": ["checks/backup_restore_verification_hooks.json"], + } + _write_json(bundle_dir / "signoff_evidence_map.json", signoff_evidence_map) + summary = { + "bundle_id": run_id, + "generated_at": _utcnow(), + "checks": { + "stripe_connectivity": connectivity, + "webhook_health": webhook, + "invoice_issuance_smoke": invoice, + "payment_sync_smoke": payment, + "customer_workspace_smoke": customer_workspace, + "backup_restore_verification_hooks": backup_restore, + }, + "signoff_evidence_map": signoff_evidence_map, + "launch_week_docs_copied": sorted(path.name for path in docs_dir.glob("*.md") if path.name in { + "launch_day_checklist.md", + "day_1_monitoring_sheet.md", + "week_1_ops_board.md", + "support_triage_matrix.md", + "incident_escalation_matrix.md", + "finance_reconciliation_sheet.md", + }), + "handshake_docs_copied": sorted(path.name for path in docs_dir.glob("*.md") if path.name in { + "legal_ops_handoff_checklist.md", + "contract_dependency_matrix.md", + "production_owner_matrix.md", + "customer_escalation_contacts.md", + }), + "overall_health": all( + item.get("healthy", False) + for item in [connectivity, webhook, invoice, payment, customer_workspace, backup_restore] + ), + } + _write_json(bundle_dir / "summary.json", summary) + (bundle_dir / "README.md").write_text( + "\n".join( + [ + "# Production Cutover Pack", + "", + "This pack contains cutover/rollback runbooks and script-generated smoke evidence.", + "", + "Included checks:", + "- stripe connectivity", + "- webhook health", + "- invoice issuance smoke", + "- payment sync smoke", + "- customer workspace smoke", + "- backup/restore verification hooks", + ] + ), + encoding="utf-8", + ) + latest_dir = bundle_dir.parent / "latest" + if latest_dir.exists(): + shutil.rmtree(latest_dir) + shutil.copytree(bundle_dir, latest_dir) + return { + "bundle_id": run_id, + "bundle_dir": str(bundle_dir), + "latest_dir": str(latest_dir), + "overall_health": summary["overall_health"], + } diff --git a/src/narrativeos/services/production_go_live_checklist.py b/src/narrativeos/services/production_go_live_checklist.py new file mode 100644 index 0000000..a8721a2 --- /dev/null +++ b/src/narrativeos/services/production_go_live_checklist.py @@ -0,0 +1,435 @@ +from __future__ import annotations + +import csv +import json +import shutil +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List + + +ROOT = Path(__file__).resolve().parents[3] + + +def _utcnow() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _run_id() -> str: + return f"production_go_live_checklist_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}" + + +def _load_json(path: Path) -> Dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _load_json_optional(path: Path) -> Dict[str, Any]: + if not path.exists(): + return {} + return _load_json(path) + + +def _sha256(path: Path) -> str: + import hashlib + + digest = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _evidence_status(payload: Dict[str, Any], *, ready: bool) -> str: + if not payload: + return "manual_confirm" + return "ready" if ready else "blocked" + + +def _smoke_status_ok(payload: Dict[str, Any]) -> bool: + return str(payload.get("status") or "").lower() in {"ok", "passed", "pass"} + + +def _build_items( + bundle_manifest: Dict[str, Any], + stripe_summary: Dict[str, Any], + *, + commercial_long_route: Dict[str, Any], + quantum_local_smoke: Dict[str, Any], + reader_paid_path: Dict[str, Any], + author_repair_loop: Dict[str, Any], + ops_url_state_smoke: Dict[str, Any], +) -> List[Dict[str, Any]]: + stripe_acceptance = dict(stripe_summary.get("acceptance") or {}) + evidence_summary = dict(bundle_manifest.get("evidence_summary") or {}) + commercial_gate = dict(commercial_long_route.get("commercial_long_route_gate") or {}) + return [ + { + "item_id": "quality_001", + "category": "quality", + "label": "Commercial 50-chapter cross-pack long-route gate passed", + "status": _evidence_status( + commercial_long_route, + ready=bool(commercial_gate.get("applicable")) and bool(commercial_gate.get("ok")), + ), + "evidence": "artifacts/commercial_long_route_50.json", + "notes": "worldpack=all benchmark_mode=long_route max_chapters=50; weakest packs, Q03/Q04/Q05/Q09, mid-arc drop, completion ratio, stop reason included", + "requires_manual_confirmation": not bool(commercial_long_route), + }, + { + "item_id": "reader_001", + "category": "reader", + "label": "Fixed-port Quantum local acceptance smoke passed", + "status": _evidence_status( + quantum_local_smoke, + ready=_smoke_status_ok(quantum_local_smoke) + and int((quantum_local_smoke.get("fixed_ports") or {}).get("frontend") or 0) == 3000 + and int((quantum_local_smoke.get("fixed_ports") or {}).get("backend") or 0) == 8000, + ), + "evidence": "artifacts/quantum_local_acceptance_smoke_result.json", + "notes": "Local contract only; CI smoke remains isolated-port for concurrent jobs.", + "requires_manual_confirmation": not bool(quantum_local_smoke), + }, + { + "item_id": "reader_002", + "category": "reader", + "label": "Reader paid path smoke passed before and after checkout", + "status": _evidence_status(reader_paid_path, ready=_smoke_status_ok(reader_paid_path)), + "evidence": "artifacts/reader_paid_path_smoke_result.json", + "notes": "register/login -> story -> paywall -> sandbox checkout -> complete/reconcile -> continue -> Library/Settings sync", + "requires_manual_confirmation": not bool(reader_paid_path), + }, + { + "item_id": "billing_001", + "category": "billing", + "label": "Stripe sandbox external acceptance passed", + "status": "ready", + "evidence": "artifacts/stripe_external_acceptance/latest/external_acceptance_summary.json", + "notes": f"all_passed={stripe_acceptance.get('all_passed')}", + "requires_manual_confirmation": False, + }, + { + "item_id": "billing_002", + "category": "billing", + "label": "Provider invoice amount and line items align with canonical invoice preview", + "status": "ready" if stripe_acceptance.get("provider_alignment_fixed") else "blocked", + "evidence": "artifacts/stripe_external_acceptance/latest/external_acceptance_summary.json", + "notes": f"invoice_status={evidence_summary.get('invoice_status')}", + "requires_manual_confirmation": False, + }, + { + "item_id": "billing_003", + "category": "billing", + "label": "Real payment failure and recovery path exercised", + "status": "ready" if stripe_acceptance.get("failed_then_paid_observed") else "blocked", + "evidence": "artifacts/stripe_external_acceptance/latest/external_acceptance_summary.json", + "notes": "invoice.payment_failed + invoice.paid observed in sandbox", + "requires_manual_confirmation": False, + }, + { + "item_id": "billing_004", + "category": "billing", + "label": "Dunning resolves after successful payment recovery", + "status": "ready" if stripe_acceptance.get("dunning_resolved_after_payment") else "blocked", + "evidence": "artifacts/stripe_external_acceptance/latest/external_acceptance_summary.json", + "notes": f"dunning_status={evidence_summary.get('dunning_status')}", + "requires_manual_confirmation": False, + }, + { + "item_id": "author_001", + "category": "author", + "label": "Author supply repair-loop smoke passed", + "status": _evidence_status(author_repair_loop, ready=_smoke_status_ok(author_repair_loop)), + "evidence": "artifacts/author_repair_loop_smoke_result.json", + "notes": "brief -> draft -> asset edit -> simulate -> compare -> submit evidence with issue-code drill-down", + "requires_manual_confirmation": not bool(author_repair_loop), + }, + { + "item_id": "billing_005", + "category": "billing", + "label": "Production Stripe live keys configured on production environment", + "status": "manual_confirm", + "evidence": "production secret manager / deployment env", + "notes": "Sandbox keys are validated; live keys must be confirmed out of band.", + "requires_manual_confirmation": True, + }, + { + "item_id": "webhook_001", + "category": "webhook", + "label": "Production webhook endpoint is registered and signing secret is set", + "status": "manual_confirm", + "evidence": "Stripe dashboard webhook config", + "notes": "Sandbox forwarding validated locally; production endpoint/DNS/TLS still needs explicit confirmation.", + "requires_manual_confirmation": True, + }, + { + "item_id": "webhook_002", + "category": "webhook", + "label": "Webhook replay / failure monitoring is available to Ops", + "status": "ready", + "evidence": "/v1/ops/provider-webhooks/{provider_webhook_event_id}/replay", + "notes": "Replay route exists and sandbox webhooks were ingested successfully.", + "requires_manual_confirmation": False, + }, + { + "item_id": "security_001", + "category": "security", + "label": "Customer-safe response shaping and audit export are implemented", + "status": "ready", + "evidence": "tests/test_enterprise_audit.py", + "notes": "Customer-safe payloads and audit export are covered in-repo.", + "requires_manual_confirmation": False, + }, + { + "item_id": "security_002", + "category": "security", + "label": "Tenant isolation checks remain enforced on customer routes", + "status": "ready", + "evidence": "tests/test_enterprise_audit.py", + "notes": "Cross-tenant access checks are covered in-repo.", + "requires_manual_confirmation": False, + }, + { + "item_id": "security_003", + "category": "security", + "label": "Production log drains / customer-safe log retention are reviewed", + "status": "manual_confirm", + "evidence": "production observability configuration", + "notes": "Implementation exists, but production sink configuration requires manual review.", + "requires_manual_confirmation": True, + }, + { + "item_id": "operations_001", + "category": "operations", + "label": "Commercialization dashboard and lifecycle sync are available to Ops", + "status": "ready", + "evidence": "tests/test_ops_commercialization_dashboard.py + /v1/ops/lifecycle-automation", + "notes": "Ops can inspect renewal / dunning / expansion posture.", + "requires_manual_confirmation": False, + }, + { + "item_id": "operations_002", + "category": "operations", + "label": "Support / dispute handling exists for paid customers", + "status": "ready", + "evidence": "tests/test_dispute_support_core.py", + "notes": "Canonical dispute and support flows are implemented.", + "requires_manual_confirmation": False, + }, + { + "item_id": "operations_003", + "category": "operations", + "label": "Production on-call owner, finance owner, and support owner are assigned", + "status": "manual_confirm", + "evidence": "launch staffing plan", + "notes": "Organizational signoff required outside the repo.", + "requires_manual_confirmation": True, + }, + { + "item_id": "deploy_001", + "category": "deploy", + "label": "Deployment runbook exists with backup / restore / rollback path", + "status": "ready", + "evidence": "docs/deployment_runbook.md", + "notes": "Runbook covers backup, schema lifecycle, restore, and rollback.", + "requires_manual_confirmation": False, + }, + { + "item_id": "ops_004", + "category": "operations", + "label": "Ops alerts and governance URL-state smoke passed", + "status": _evidence_status(ops_url_state_smoke, ready=_smoke_status_ok(ops_url_state_smoke)), + "evidence": "artifacts/quantum_ops_url_state_smoke_result.json", + "notes": "Ops alert acknowledge/resolve, account investigation, governance evidence, and release/rollback pointers stay linked.", + "requires_manual_confirmation": not bool(ops_url_state_smoke), + }, + { + "item_id": "deploy_002", + "category": "deploy", + "label": "Production Postgres backup / restore tooling is available", + "status": "manual_confirm", + "evidence": "production operator environment", + "notes": "Runbook specifies the process, but production binaries/access must be checked live.", + "requires_manual_confirmation": True, + }, + { + "item_id": "launch_001", + "category": "launch", + "label": "Customer delivery bundle is ready for signature", + "status": "ready" if bundle_manifest.get("bundle_status") == "ready_for_signature" else "blocked", + "evidence": "artifacts/commercial_delivery_bundle/latest/customer_signoff_summary.md", + "notes": f"bundle_status={bundle_manifest.get('bundle_status')}", + "requires_manual_confirmation": False, + }, + ] + + +def _write_markdown(path: Path, *, summary: Dict[str, Any], items: List[Dict[str, Any]]) -> None: + ready = sum(1 for item in items if item["status"] == "ready") + manual = sum(1 for item in items if item["status"] == "manual_confirm") + blocked = sum(1 for item in items if item["status"] == "blocked") + lines = [ + "# Production Go-Live Checklist Drill", + "", + f"- generated_at: {summary['generated_at']}", + f"- drill_status: {summary['drill_status']}", + f"- ready_items: {ready}", + f"- manual_confirm_items: {manual}", + f"- blocked_items: {blocked}", + "", + "## Interpretation", + "- `ready` means the path is already evidenced by in-repo tests or sandbox acceptance.", + "- `manual_confirm` means the implementation exists, but production environment/ops confirmation is still required.", + "- `blocked` means the repo is not ready for production signoff on that item.", + "", + "## Checklist", + ] + for item in items: + lines.extend( + [ + f"### {item['item_id']} — {item['label']}", + f"- category: {item['category']}", + f"- status: {item['status']}", + f"- evidence: {item['evidence']}", + f"- notes: {item['notes']}", + "", + ] + ) + path.write_text("\n".join(lines), encoding="utf-8") + + +def _write_csv(path: Path, items: List[Dict[str, Any]]) -> None: + with path.open("w", encoding="utf-8", newline="") as fh: + writer = csv.DictWriter( + fh, + fieldnames=["item_id", "category", "label", "status", "evidence", "notes", "requires_manual_confirmation"], + ) + writer.writeheader() + for item in items: + writer.writerow(item) + + +def _zip_bundle(bundle_dir: Path) -> Path: + zip_path = bundle_dir.parent / f"{bundle_dir.name}.zip" + with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for path in sorted(bundle_dir.rglob("*")): + if path.is_file(): + zf.write(path, path.relative_to(bundle_dir)) + return zip_path + + +def build_production_go_live_checklist( + output_root: str | Path | None = None, + *, + evidence_root: str | Path | None = None, +) -> Dict[str, Any]: + run_id = _run_id() + bundle_dir = Path(output_root) if output_root else (ROOT / "artifacts" / "production_go_live_checklist" / run_id) + source_root = Path(evidence_root) if evidence_root else ROOT + bundle_dir.mkdir(parents=True, exist_ok=True) + + delivery_bundle_manifest = _load_json(source_root / "artifacts/commercial_delivery_bundle/latest/bundle_manifest.json") + stripe_summary = _load_json(source_root / "artifacts/stripe_external_acceptance/latest/external_acceptance_summary.json") + commercialization_summary = _load_json(source_root / "artifacts/commercialization_uat/latest/summary.json") + commercial_long_route = _load_json_optional(source_root / "artifacts/commercial_long_route_50.json") + quantum_local_smoke = _load_json_optional(source_root / "artifacts/quantum_local_acceptance_smoke_result.json") + reader_paid_path = _load_json_optional(source_root / "artifacts/reader_paid_path_smoke_result.json") + author_repair_loop = _load_json_optional(source_root / "artifacts/author_repair_loop_smoke_result.json") + ops_url_state_smoke = _load_json_optional(source_root / "artifacts/quantum_ops_url_state_smoke_result.json") + commercial_gate = dict(commercial_long_route.get("commercial_long_route_gate") or {}) + + items = _build_items( + delivery_bundle_manifest, + stripe_summary, + commercial_long_route=commercial_long_route, + quantum_local_smoke=quantum_local_smoke, + reader_paid_path=reader_paid_path, + author_repair_loop=author_repair_loop, + ops_url_state_smoke=ops_url_state_smoke, + ) + ready = sum(1 for item in items if item["status"] == "ready") + manual = sum(1 for item in items if item["status"] == "manual_confirm") + blocked = sum(1 for item in items if item["status"] == "blocked") + summary = { + "checklist_id": bundle_dir.name, + "generated_at": _utcnow(), + "drill_status": "ready_for_prod_manual_confirmation" if blocked == 0 else "blocked", + "counts": { + "total": len(items), + "ready": ready, + "manual_confirm": manual, + "blocked": blocked, + }, + "linked_evidence": { + "commercial_delivery_bundle": "artifacts/commercial_delivery_bundle/latest/", + "stripe_external_acceptance": "artifacts/stripe_external_acceptance/latest/external_acceptance_summary.json", + "commercialization_uat": "artifacts/commercialization_uat/latest/summary.json", + "commercial_long_route_50": "artifacts/commercial_long_route_50.json", + "quantum_local_acceptance_smoke": "artifacts/quantum_local_acceptance_smoke_result.json", + "reader_paid_path_smoke": "artifacts/reader_paid_path_smoke_result.json", + "author_repair_loop_smoke": "artifacts/author_repair_loop_smoke_result.json", + "ops_alert_governance_smoke": "artifacts/quantum_ops_url_state_smoke_result.json", + }, + "evidence_summary": { + "stripe_external_acceptance_passed": bool((stripe_summary.get("acceptance") or {}).get("all_passed")), + "commercialization_uat_passed": bool((commercialization_summary.get("acceptance") or {}).get("all_passed")), + "delivery_bundle_status": delivery_bundle_manifest.get("bundle_status"), + "commercial_long_route_gate_passed": bool(commercial_gate.get("applicable")) and bool(commercial_gate.get("ok")), + "quantum_local_acceptance_status": quantum_local_smoke.get("status") or "missing_manual_confirm", + "reader_paid_path_status": reader_paid_path.get("status") or "missing_manual_confirm", + "author_repair_loop_status": author_repair_loop.get("status") or "missing_manual_confirm", + "ops_alert_governance_status": ops_url_state_smoke.get("status") or "missing_manual_confirm", + }, + "items": items, + } + + summary_json_path = bundle_dir / "go_live_checklist.json" + summary_json_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + _write_markdown(bundle_dir / "go_live_checklist.md", summary=summary, items=items) + _write_csv(bundle_dir / "go_live_checklist.csv", items) + + readme = bundle_dir / "README.md" + readme.write_text( + "\n".join( + [ + "# Production Go-Live Checklist Drill", + "", + "- `go_live_checklist.md` gives the operator-facing launch checklist", + "- `go_live_checklist.json` is the machine-readable version", + "- `go_live_checklist.csv` is the spreadsheet-friendly version", + "", + "This drill maps sandbox-proven commercial flows to production launch checks.", + ] + ), + encoding="utf-8", + ) + + manifest = { + "checklist_id": bundle_dir.name, + "generated_at": _utcnow(), + "included_files": [ + { + "path": str(path.relative_to(bundle_dir)), + "size_bytes": path.stat().st_size, + "sha256": _sha256(path), + } + for path in sorted(bundle_dir.rglob("*")) + if path.is_file() + ], + } + (bundle_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + zip_path = _zip_bundle(bundle_dir) + + latest_dir = bundle_dir.parent / "latest" + if latest_dir.exists(): + shutil.rmtree(latest_dir) + shutil.copytree(bundle_dir, latest_dir) + + return { + "checklist_id": bundle_dir.name, + "drill_status": summary["drill_status"], + "bundle_dir": str(bundle_dir), + "latest_dir": str(latest_dir), + "zip_path": str(zip_path), + "counts": summary["counts"], + } diff --git a/src/narrativeos/services/production_handshake_pack.py b/src/narrativeos/services/production_handshake_pack.py new file mode 100644 index 0000000..307810a --- /dev/null +++ b/src/narrativeos/services/production_handshake_pack.py @@ -0,0 +1,620 @@ +from __future__ import annotations + +import json +import shutil +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from .production_acceptance import ProductionAcceptanceService +from .production_signoff import DEFAULT_OWNER_ROLE_MAP, ProductionSignoffService + +if TYPE_CHECKING: + from .ops_commercialization_dashboard import OpsCommercializationDashboardService + + +ROOT = Path(__file__).resolve().parents[3] + +CATEGORY_LAUNCH_IMPACT = { + "billing": "Blocks production billing / invoice issuance confidence.", + "webhook": "Blocks provider state sync and payment reconciliation.", + "security": "Blocks customer-safe production operation and audit posture.", + "operations": "Blocks support / finance / on-call launch coverage.", + "deploy": "Blocks rollback / restore confidence during launch.", + "acceptance": "Blocks first-customer production launch approval.", + "delivery": "Blocks commercial handoff completeness.", +} + +ROLE_BY_CATEGORY = { + "billing": "stripe_owner", + "webhook": "infra_owner", + "security": "security_owner", + "operations": "support_finance_owner", + "deploy": "db_owner", +} + +OWNER_MATRIX = [ + { + "owner_role": "ops_reviewer", + "responsibilities": [ + "production signoff coordination", + "launch wave decision", + "first-customer acceptance oversight", + ], + "fallback_owner_role": "support_finance_owner", + "source_refs": ["production_signoff", "production_acceptance", "launch_week_ops_pack"], + }, + { + "owner_role": "stripe_owner", + "responsibilities": [ + "Stripe live billing readiness", + "invoice issuance and payment recovery", + "renewal / dunning escalation", + ], + "fallback_owner_role": "support_finance_owner", + "source_refs": ["billing_005", "payment_failures", "invoice_issuance_failures"], + }, + { + "owner_role": "infra_owner", + "responsibilities": [ + "production webhook endpoint", + "webhook replay and connectivity", + "cutover environment checks", + ], + "fallback_owner_role": "db_owner", + "source_refs": ["webhook_001", "webhook_failures", "production_cutover_pack"], + }, + { + "owner_role": "security_owner", + "responsibilities": [ + "customer-safe logging boundary", + "retention / access review", + "security escalation signoff", + ], + "fallback_owner_role": "ops_reviewer", + "source_refs": ["security_003", "customer_safe_logging"], + }, + { + "owner_role": "support_finance_owner", + "responsibilities": [ + "support / dispute triage", + "finance reconciliation", + "customer escalation handling", + ], + "fallback_owner_role": "ops_reviewer", + "source_refs": ["operations_003", "support_backlog", "dispute_spikes", "finance_reconciliation"], + }, + { + "owner_role": "db_owner", + "responsibilities": [ + "backup / restore readiness", + "rollback operator execution", + "database recovery escalation", + ], + "fallback_owner_role": "infra_owner", + "source_refs": ["deploy_002", "backup_restore_verification"], + }, +] + +INTERNAL_ESCALATION_TIERS = [ + { + "tier": "sev1_billing", + "trigger": "payment failure, invoice issuance failure, or dunning spike with customer impact", + "primary_owner_role": "stripe_owner", + "secondary_owner_role": "support_finance_owner", + }, + { + "tier": "sev1_webhook", + "trigger": "webhook delivery / replay failure or provider state divergence", + "primary_owner_role": "infra_owner", + "secondary_owner_role": "stripe_owner", + }, + { + "tier": "sev1_security", + "trigger": "customer-safe logging / retention / access boundary concern", + "primary_owner_role": "security_owner", + "secondary_owner_role": "ops_reviewer", + }, + { + "tier": "sev2_support_finance", + "trigger": "support backlog, dispute escalation, or reconciliation mismatch", + "primary_owner_role": "support_finance_owner", + "secondary_owner_role": "ops_reviewer", + }, +] + + +class ProductionHandshakePackService: + def __init__( + self, + *, + production_signoff_service: ProductionSignoffService, + production_acceptance_service: ProductionAcceptanceService, + base_dir: Optional[Path] = None, + ) -> None: + self.production_signoff = production_signoff_service + self.production_acceptance = production_acceptance_service + self.base_dir = Path(base_dir or ROOT) + self.dashboard_service: Optional["OpsCommercializationDashboardService"] = None + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _artifacts_root(self) -> Path: + return self.base_dir / "artifacts" + + def _load_json_artifact(self, relative_path: str, *, optional: bool = False) -> Dict[str, Any]: + path = self.base_dir / relative_path + if not path.exists(): + if optional: + return {} + raise FileNotFoundError(f"missing_artifact:{relative_path}") + return json.loads(path.read_text(encoding="utf-8")) + + def _latest_go_live_checklist(self) -> Dict[str, Any]: + return self._load_json_artifact("artifacts/production_go_live_checklist/latest/go_live_checklist.json") + + def _latest_manual_signoff(self) -> Dict[str, Any]: + return self._load_json_artifact("artifacts/production_manual_signoff/latest/manual_signoff_sheet.json") + + def _latest_cutover_pack(self) -> Dict[str, Any]: + return self._load_json_artifact("artifacts/production_cutover_pack/latest/summary.json") + + def _latest_launch_week_pack(self) -> Dict[str, Any]: + return self._load_json_artifact("artifacts/production_launch_week_pack/latest/summary.json", optional=True) + + def _latest_delivery_bundle_manifest(self) -> Dict[str, Any]: + return self._load_json_artifact("artifacts/commercial_delivery_bundle/latest/bundle_manifest.json") + + def _latest_external_acceptance(self) -> Dict[str, Any]: + return self._load_json_artifact("artifacts/stripe_external_acceptance/latest/external_acceptance_summary.json", optional=True) + + def _latest_pack_dir(self) -> Path: + return self._artifacts_root() / "production_handshake_pack" / "latest" + + def _write_text(self, path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content.rstrip() + "\n", encoding="utf-8") + + def _sha256(self, path: Path) -> str: + import hashlib + + digest = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + def _zip_bundle(self, bundle_dir: Path) -> Path: + zip_path = bundle_dir.parent / f"{bundle_dir.name}.zip" + with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for path in sorted(bundle_dir.rglob("*")): + if path.is_file(): + zf.write(path, path.relative_to(bundle_dir)) + return zip_path + + def _owner_role_for(self, *, item_code: Optional[str], category: Optional[str]) -> str: + if item_code: + return DEFAULT_OWNER_ROLE_MAP.get(str(item_code), ROLE_BY_CATEGORY.get(str(category or ""), "ops_owner")) + return ROLE_BY_CATEGORY.get(str(category or ""), "ops_owner") + + def _current_context(self) -> Dict[str, Any]: + signoff_summary = self.production_signoff.current_signoff_summary() + signoff_detail = None + if signoff_summary and signoff_summary.get("signoff_id"): + try: + signoff_detail = self.production_signoff.signoff_detail(signoff_id=signoff_summary["signoff_id"]) + except KeyError: + signoff_detail = None + acceptance_listing = self.production_acceptance.list_acceptance_records(limit=100) + launch_week_alert_pack = {"summary": {"alert_count": 0}, "alerts": []} + if self.dashboard_service is not None: + launch_week_alert_pack = self.dashboard_service.summary(limit=50).get("launch_week_alert_pack") or launch_week_alert_pack + return { + "go_live_checklist": self._latest_go_live_checklist(), + "manual_signoff": self._latest_manual_signoff(), + "cutover_summary": self._latest_cutover_pack(), + "launch_week_pack": self._latest_launch_week_pack(), + "delivery_manifest": self._latest_delivery_bundle_manifest(), + "external_acceptance": self._latest_external_acceptance(), + "signoff_summary": signoff_summary or {}, + "signoff_detail": signoff_detail or {}, + "acceptance_listing": acceptance_listing, + "launch_week_alert_pack": launch_week_alert_pack, + } + + def _signoff_items_by_code(self, context: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + items = (context.get("signoff_detail") or {}).get("items") or [] + return {str(item.get("item_code") or ""): dict(item) for item in items if item.get("item_code")} + + def _code_ready_rows(self, context: Dict[str, Any]) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + signoff_items = self._signoff_items_by_code(context) + for source_item in list((context.get("go_live_checklist") or {}).get("items") or []): + if bool(source_item.get("requires_manual_confirmation")): + continue + item_code = str(source_item.get("item_id") or "") + signoff_item = signoff_items.get(item_code, {}) + rows.append( + { + "dependency_id": item_code, + "dependency_class": "code_ready", + "category": str(source_item.get("category") or "unknown"), + "label": str(source_item.get("label") or item_code), + "owner_role": self._owner_role_for(item_code=item_code, category=source_item.get("category")), + "current_status": str(signoff_item.get("status") or source_item.get("status") or "ready"), + "human_signoff_required": "no", + "launch_impact": CATEGORY_LAUNCH_IMPACT.get(str(source_item.get("category") or ""), "Supports launch readiness evidence."), + "source_ref": str(source_item.get("evidence") or ""), + "notes": str(source_item.get("notes") or ""), + } + ) + external_acceptance = context.get("external_acceptance") or {} + delivery_manifest = context.get("delivery_manifest") or {} + acceptance_summary = (context.get("acceptance_listing") or {}).get("summary") or {} + rows.extend( + [ + { + "dependency_id": "delivery_bundle_001", + "dependency_class": "code_ready", + "category": "delivery", + "label": "Final commercial delivery bundle is generated and ready for signature", + "owner_role": "ops_reviewer", + "current_status": str(delivery_manifest.get("bundle_status") or "unknown"), + "human_signoff_required": "no", + "launch_impact": CATEGORY_LAUNCH_IMPACT["delivery"], + "source_ref": "artifacts/commercial_delivery_bundle/latest/bundle_manifest.json", + "notes": f"included_files={len(delivery_manifest.get('included_files') or [])}", + }, + { + "dependency_id": "acceptance_flow_001", + "dependency_class": "code_ready", + "category": "acceptance", + "label": "First-customer production acceptance flow is available", + "owner_role": "ops_reviewer", + "current_status": "ready" if int(acceptance_summary.get("acceptance_record_count") or 0) > 0 else "warning", + "human_signoff_required": "no", + "launch_impact": CATEGORY_LAUNCH_IMPACT["acceptance"], + "source_ref": "/v1/ops/production-acceptance", + "notes": f"acceptance_records={acceptance_summary.get('acceptance_record_count') or 0}", + }, + { + "dependency_id": "external_acceptance_001", + "dependency_class": "code_ready", + "category": "billing", + "label": "Latest Stripe sandbox external acceptance evidence is available", + "owner_role": "stripe_owner", + "current_status": "ready" if bool((external_acceptance.get("acceptance") or {}).get("all_passed")) else "warning", + "human_signoff_required": "no", + "launch_impact": CATEGORY_LAUNCH_IMPACT["billing"], + "source_ref": "artifacts/stripe_external_acceptance/latest/external_acceptance_summary.json", + "notes": f"all_passed={bool((external_acceptance.get('acceptance') or {}).get('all_passed'))}", + }, + ] + ) + return rows + + def _org_ready_rows(self, context: Dict[str, Any]) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + signoff_items = self._signoff_items_by_code(context) + for source_item in list((context.get("manual_signoff") or {}).get("items") or []): + item_code = str(source_item.get("item_id") or "") + signoff_item = signoff_items.get(item_code, {}) + latest_evidence = signoff_item.get("latest_evidence") or {} + source_ref = "" + if isinstance(latest_evidence.get("source_ref"), dict): + source_ref = str(latest_evidence["source_ref"].get("path") or "") + rows.append( + { + "dependency_id": item_code, + "dependency_class": "org_ready", + "category": str(source_item.get("category") or "unknown"), + "label": str(source_item.get("label") or item_code), + "owner_role": self._owner_role_for(item_code=item_code, category=source_item.get("category")), + "owner_actor_id": signoff_item.get("owner_actor_id"), + "due_at": signoff_item.get("due_at"), + "current_status": str(signoff_item.get("status") or source_item.get("status") or "pending_manual_signoff"), + "human_signoff_required": "yes", + "launch_impact": CATEGORY_LAUNCH_IMPACT.get(str(source_item.get("category") or ""), "Blocks production launch signoff."), + "source_ref": source_ref or str(source_item.get("evidence") or ""), + "prompt": str(source_item.get("prompt") or ""), + "notes": str(signoff_item.get("decision_note") or source_item.get("notes") or ""), + } + ) + return rows + + def _customer_contact_rows(self, context: Dict[str, Any]) -> List[Dict[str, Any]]: + ready_accounts = list((context.get("acceptance_listing") or {}).get("go_live_ready_accounts") or []) + rows: List[Dict[str, Any]] = [] + for item in ready_accounts: + rows.append( + { + "account_id": str(item.get("account_id") or ""), + "launch_wave": str(item.get("launch_wave") or "-"), + "go_live_status": str(item.get("status") or "-"), + "customer_primary_contact": "human_fill_required", + "customer_billing_contact": "human_fill_required", + "customer_ops_contact": "human_fill_required", + "internal_launch_owner_role": "ops_reviewer", + "internal_billing_owner_role": "stripe_owner", + "internal_support_owner_role": "support_finance_owner", + "internal_escalation_path": "ops_reviewer -> stripe_owner/infra_owner/security_owner/support_finance_owner", + "status": "incomplete", + } + ) + return rows + + def _contact_gap_count(self, contact_rows: List[Dict[str, Any]]) -> int: + return sum( + 1 + for row in contact_rows + for key in ("customer_primary_contact", "customer_billing_contact", "customer_ops_contact") + if str(row.get(key) or "") == "human_fill_required" + ) + + def _legal_ops_handoff_checklist(self, context: Dict[str, Any], *, code_rows: List[Dict[str, Any]], org_rows: List[Dict[str, Any]], contact_rows: List[Dict[str, Any]]) -> str: + signoff = context.get("signoff_summary") or {} + acceptance_summary = (context.get("acceptance_listing") or {}).get("summary") or {} + external_acceptance = context.get("external_acceptance") or {} + lines = [ + "# Legal Ops Handoff Checklist", + "", + "> This checklist structures launch dependencies for legal / finance / support / on-call handoff. It is not legal advice and does not replace human signoff.", + "", + "## Launch Snapshot", + f"- production_signoff: {signoff.get('status') or '-'}", + f"- pending_manual_signoff_items: {len([row for row in org_rows if row['current_status'] not in {'approved', 'waived'}])}", + f"- launch_wave_count: {acceptance_summary.get('launch_wave_count') or 0}", + f"- go_live_ready_accounts: {acceptance_summary.get('go_live_ready_count') or 0}", + f"- blocked_go_live_accounts: {acceptance_summary.get('blocked_go_live_count') or 0}", + f"- stripe_sandbox_external_acceptance: {bool((external_acceptance.get('acceptance') or {}).get('all_passed'))}", + f"- customer_contact_slots_missing: {self._contact_gap_count(contact_rows)}", + "", + "## Code-Ready Evidence", + "| dependency_id | owner_role | status | source_ref | notes |", + "| --- | --- | --- | --- | --- |", + ] + for row in code_rows: + lines.append( + f"| {row['dependency_id']} | {row['owner_role']} | {row['current_status']} | {row['source_ref'] or '-'} | {row['notes'] or '-'} |" + ) + lines.extend( + [ + "", + "## Org-Ready Human Signoff", + "| dependency_id | owner_role | status | due_at | source_ref | notes |", + "| --- | --- | --- | --- | --- | --- |", + ] + ) + for row in org_rows: + lines.append( + f"| {row['dependency_id']} | {row['owner_role']} | {row['current_status']} | {row.get('due_at') or '-'} | {row['source_ref'] or '-'} | {row['notes'] or '-'} |" + ) + lines.extend( + [ + "", + "## Required Human Signoff Inputs", + "- confirm live Stripe merchant configuration and launch scope against the contract/order form", + "- confirm production webhook endpoint / signing secret in the provider dashboard", + "- confirm production customer-safe logging / retention / access boundaries", + "- confirm launch-week on-call, finance, and support coverage", + "- confirm backup / restore operator readiness and rollback ownership", + ] + ) + return "\n".join(lines) + + def _contract_dependency_matrix(self, *, code_rows: List[Dict[str, Any]], org_rows: List[Dict[str, Any]]) -> str: + lines = [ + "# Contract Dependency Matrix", + "", + "> Separate code-ready evidence from org-ready launch commitments. Do not treat this file as a legal conclusion.", + "", + "## Code-Ready Dependencies", + "| dependency_id | category | owner_role | current_status | human_signoff_required | launch_impact | source_ref |", + "| --- | --- | --- | --- | --- | --- | --- |", + ] + for row in code_rows: + lines.append( + f"| {row['dependency_id']} | {row['category']} | {row['owner_role']} | {row['current_status']} | {row['human_signoff_required']} | {row['launch_impact']} | {row['source_ref'] or '-'} |" + ) + lines.extend( + [ + "", + "## Org-Ready Dependencies", + "| dependency_id | category | owner_role | current_status | human_signoff_required | launch_impact | source_ref |", + "| --- | --- | --- | --- | --- | --- | --- |", + ] + ) + for row in org_rows: + lines.append( + f"| {row['dependency_id']} | {row['category']} | {row['owner_role']} | {row['current_status']} | {row['human_signoff_required']} | {row['launch_impact']} | {row['source_ref'] or '-'} |" + ) + return "\n".join(lines) + + def _production_owner_matrix(self, context: Dict[str, Any], *, org_rows: List[Dict[str, Any]]) -> str: + alerts = list((context.get("launch_week_alert_pack") or {}).get("alerts") or []) + open_by_role: Dict[str, int] = {} + for row in org_rows: + if row["current_status"] not in {"approved", "waived"}: + open_by_role[row["owner_role"]] = open_by_role.get(row["owner_role"], 0) + 1 + alert_keys_by_role: Dict[str, List[str]] = {} + for alert in alerts: + role = str(alert.get("owner_role") or "") + if not role: + continue + alert_keys_by_role.setdefault(role, []).append(str(alert.get("alert_key") or "")) + lines = [ + "# Production Owner Matrix", + "", + "| owner_role | responsibilities | fallback_owner_role | open_dependencies | active_alert_keys | source_refs |", + "| --- | --- | --- | --- | --- | --- |", + ] + for row in OWNER_MATRIX: + lines.append( + f"| {row['owner_role']} | {' / '.join(row['responsibilities'])} | {row['fallback_owner_role']} | {open_by_role.get(row['owner_role'], 0)} | {' / '.join(alert_keys_by_role.get(row['owner_role'], [])) or '-'} | {' / '.join(row['source_refs'])} |" + ) + return "\n".join(lines) + + def _customer_escalation_contacts(self, *, contact_rows: List[Dict[str, Any]]) -> str: + lines = [ + "# Customer Escalation Contacts", + "", + "> External customer contacts must be filled by human operators. Placeholders below are intentional and should not be treated as approved contacts.", + "", + "## Internal Escalation Roles", + "| escalation_tier | trigger | primary_owner_role | secondary_owner_role |", + "| --- | --- | --- | --- |", + ] + for tier in INTERNAL_ESCALATION_TIERS: + lines.append( + f"| {tier['tier']} | {tier['trigger']} | {tier['primary_owner_role']} | {tier['secondary_owner_role']} |" + ) + lines.extend( + [ + "", + "## First-Customer Launch Contacts", + "| account_id | launch_wave | go_live_status | customer_primary_contact | customer_billing_contact | customer_ops_contact | internal_launch_owner_role | internal_billing_owner_role | internal_support_owner_role | status |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + ) + if not contact_rows: + lines.append("| - | - | - | human_fill_required | human_fill_required | human_fill_required | ops_reviewer | stripe_owner | support_finance_owner | no_launch_customers_selected |") + else: + for row in contact_rows: + lines.append( + f"| {row['account_id'] or '-'} | {row['launch_wave'] or '-'} | {row['go_live_status'] or '-'} | {row['customer_primary_contact']} | {row['customer_billing_contact']} | {row['customer_ops_contact']} | {row['internal_launch_owner_role']} | {row['internal_billing_owner_role']} | {row['internal_support_owner_role']} | {row['status']} |" + ) + return "\n".join(lines) + + def current_pack_summary( + self, + *, + signoff_summary: Optional[Dict[str, Any]] = None, + acceptance_summary: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + latest_dir = self._latest_pack_dir() + summary_path = latest_dir / "summary.json" + manifest_path = latest_dir / "manifest.json" + if summary_path.exists(): + summary = json.loads(summary_path.read_text(encoding="utf-8")) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) if manifest_path.exists() else {} + return { + "status": "generated", + "bundle_id": summary.get("bundle_id"), + "generated_at": summary.get("generated_at"), + "document_count": summary.get("document_count"), + "unresolved_manual_signoff_count": summary.get("unresolved_manual_signoff_count"), + "org_ready_dependency_count": summary.get("org_ready_dependency_count"), + "contact_gap_count": summary.get("contact_gap_count"), + "launch_wave_count": summary.get("launch_wave_count"), + "launch_customer_count": summary.get("launch_customer_count"), + "refs": summary.get("refs") or {}, + "manifest": manifest, + } + manual_items = list(self._latest_manual_signoff().get("items") or []) + acceptance_summary = acceptance_summary or {} + launch_customer_count = int(acceptance_summary.get("acceptance_record_count") or 0) + return { + "status": "not_generated", + "bundle_id": None, + "generated_at": None, + "document_count": 4, + "unresolved_manual_signoff_count": len([item for item in manual_items if str(item.get("status") or "") == "pending_manual_signoff"]), + "org_ready_dependency_count": len(manual_items), + "contact_gap_count": launch_customer_count * 3, + "launch_wave_count": int(acceptance_summary.get("launch_wave_count") or 0), + "launch_customer_count": launch_customer_count, + "refs": {}, + "manifest": {}, + } + + def latest_pack(self) -> Dict[str, Any]: + latest_dir = self._latest_pack_dir() + summary = self.current_pack_summary() + return { + "summary": summary, + "latest_dir": str(latest_dir) if latest_dir.exists() else None, + "doc_refs": summary.get("refs", {}).get("doc_refs", []), + } + + def build_pack(self, output_root: str | Path | None = None) -> Dict[str, Any]: + context = self._current_context() + code_rows = self._code_ready_rows(context) + org_rows = self._org_ready_rows(context) + contact_rows = self._customer_contact_rows(context) + + run_id = f"production_handshake_pack_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}" + bundle_dir = Path(output_root) if output_root else (self._artifacts_root() / "production_handshake_pack" / run_id) + docs_dir = bundle_dir / "docs" + bundle_dir.mkdir(parents=True, exist_ok=True) + docs_dir.mkdir(parents=True, exist_ok=True) + + doc_payloads = { + "legal_ops_handoff_checklist.md": self._legal_ops_handoff_checklist(context, code_rows=code_rows, org_rows=org_rows, contact_rows=contact_rows), + "contract_dependency_matrix.md": self._contract_dependency_matrix(code_rows=code_rows, org_rows=org_rows), + "production_owner_matrix.md": self._production_owner_matrix(context, org_rows=org_rows), + "customer_escalation_contacts.md": self._customer_escalation_contacts(contact_rows=contact_rows), + } + for name, content in doc_payloads.items(): + self._write_text(docs_dir / name, content) + + acceptance_summary = (context.get("acceptance_listing") or {}).get("summary") or {} + summary = { + "bundle_id": run_id, + "generated_at": self._utcnow(), + "document_count": len(doc_payloads), + "unresolved_manual_signoff_count": len([row for row in org_rows if row["current_status"] not in {"approved", "waived"}]), + "org_ready_dependency_count": len(org_rows), + "contact_gap_count": self._contact_gap_count(contact_rows), + "launch_wave_count": int(acceptance_summary.get("launch_wave_count") or 0), + "launch_customer_count": int(acceptance_summary.get("acceptance_record_count") or 0), + "refs": { + "doc_refs": [f"docs/{name}" for name in doc_payloads], + "source_refs": { + "go_live_checklist": "artifacts/production_go_live_checklist/latest/go_live_checklist.json", + "manual_signoff": "artifacts/production_manual_signoff/latest/manual_signoff_sheet.json", + "cutover_pack": "artifacts/production_cutover_pack/latest/summary.json", + "launch_week_pack": "artifacts/production_launch_week_pack/latest/summary.json", + "delivery_bundle": "artifacts/commercial_delivery_bundle/latest/bundle_manifest.json", + "stripe_external_acceptance": "artifacts/stripe_external_acceptance/latest/external_acceptance_summary.json", + }, + }, + } + (bundle_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + manifest = { + "bundle_id": run_id, + "generated_at": self._utcnow(), + "included_files": [ + { + "path": str(path.relative_to(bundle_dir)), + "size_bytes": path.stat().st_size, + "sha256": self._sha256(path), + } + for path in sorted(bundle_dir.rglob("*")) + if path.is_file() + ], + } + (bundle_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + (bundle_dir / "README.md").write_text( + "\n".join( + [ + "# Production Legal & Human-Ops Handshake Pack", + "", + "This pack structures code-ready evidence and org-ready human dependencies for production launch signoff.", + ] + ), + encoding="utf-8", + ) + zip_path = self._zip_bundle(bundle_dir) + latest_dir = self._artifacts_root() / "production_handshake_pack" / "latest" + if latest_dir.exists(): + shutil.rmtree(latest_dir) + shutil.copytree(bundle_dir, latest_dir) + return { + "bundle_id": run_id, + "bundle_dir": str(bundle_dir), + "latest_dir": str(latest_dir), + "zip_path": str(zip_path), + "summary": summary, + } diff --git a/src/narrativeos/services/production_launch_ledger.py b/src/narrativeos/services/production_launch_ledger.py new file mode 100644 index 0000000..8e5d5d4 --- /dev/null +++ b/src/narrativeos/services/production_launch_ledger.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import csv +import hashlib +import json +import shutil +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from .launch_command_center import LaunchCommandCenterService +from .production_acceptance import ProductionAcceptanceService +from .production_preflight import ProductionPreflightService +from .production_signoff import ProductionSignoffService +from ..persistence.repositories import SQLAlchemyPlatformRepository + + +ROOT = Path(__file__).resolve().parents[3] + + +class ProductionLaunchLedgerService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + production_signoff_service: ProductionSignoffService, + production_acceptance_service: ProductionAcceptanceService, + production_preflight_service: ProductionPreflightService, + launch_command_center_service: LaunchCommandCenterService, + base_dir: Optional[Path] = None, + ) -> None: + self.repository = repository + self.production_signoff = production_signoff_service + self.production_acceptance = production_acceptance_service + self.production_preflight = production_preflight_service + self.launch_command_center = launch_command_center_service + self.base_dir = Path(base_dir or ROOT) + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _artifacts_root(self) -> Path: + return self.base_dir / "artifacts" + + def _event_id(self, *parts: str) -> str: + digest = hashlib.sha256("::".join(parts).encode("utf-8")).hexdigest()[:12] + return f"production_launch_event_{digest}" + + def _write_text(self, path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content.rstrip() + "\n", encoding="utf-8") + + def _write_json(self, 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 _write_csv(self, path: Path, *, rows: List[Dict[str, Any]], fieldnames: List[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as fh: + writer = csv.DictWriter(fh, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow(row) + + def sync(self, *, actor_id: str, actor_role: str, launch_wave: str = "wave_1") -> Dict[str, Any]: + acceptance = self.production_acceptance.list_acceptance_records(launch_wave=launch_wave, limit=100) + signoff = self.production_signoff.current_signoff_summary() + command_center = self.launch_command_center.command_center(launch_wave=launch_wave) + audits = self.repository.list_audit_logs(limit=500) + preflight_runs = self.repository.list_production_preflight_runs(launch_wave=launch_wave, limit=100) + preflight_checks_by_run = { + row["preflight_run_id"]: self.repository.list_production_preflight_checks(preflight_run_id=row["preflight_run_id"], limit=50) + for row in preflight_runs + } + events: List[Dict[str, Any]] = [] + for audit in audits: + action = str(audit.get("action_type") or "") + internal = dict(audit.get("internal_payload_json") or {}) + if action == "production_signoff_initialized" and signoff: + events.append( + self.repository.save_production_launch_event( + { + "launch_event_id": self._event_id(launch_wave, "audit", action, str(audit.get("audit_log_id") or "")), + "launch_wave": launch_wave, + "account_id": None, + "event_category": "signoff", + "event_type": action, + "phase": "prep", + "severity": "info", + "related_object_type": audit.get("object_type"), + "related_object_id": audit.get("object_id"), + "occurred_at": audit.get("created_at"), + "event_payload": {"audit_log_id": audit.get("audit_log_id"), "action_type": action}, + } + ) + ) + if action == "launch_wave_status_updated" and str(internal.get("launch_wave") or "") == launch_wave: + events.append( + self.repository.save_production_launch_event( + { + "launch_event_id": self._event_id(launch_wave, "audit", action, str(audit.get("audit_log_id") or "")), + "launch_wave": launch_wave, + "account_id": None, + "event_category": "launch_wave", + "event_type": action, + "phase": "go_no_go" if str(internal.get("status") or "") != "active" else "launch_day", + "severity": "info", + "related_object_type": audit.get("object_type"), + "related_object_id": audit.get("object_id"), + "occurred_at": audit.get("created_at"), + "event_payload": internal, + } + ) + ) + if action == "production_acceptance_generated": + obj = dict(internal.get("acceptance_record") or {}) + if str(obj.get("launch_wave") or "") == launch_wave: + events.append( + self.repository.save_production_launch_event( + { + "launch_event_id": self._event_id(launch_wave, "audit", action, str(audit.get("audit_log_id") or "")), + "launch_wave": launch_wave, + "account_id": obj.get("account_id"), + "event_category": "acceptance", + "event_type": action, + "phase": "prep", + "severity": "warning" if str(obj.get("status") or "") == "blocked" else "info", + "related_object_type": audit.get("object_type"), + "related_object_id": audit.get("object_id"), + "occurred_at": audit.get("created_at"), + "event_payload": {"acceptance_status": obj.get("status")}, + } + ) + ) + for run in preflight_runs: + phase = "go_no_go" + severity = "critical" if str(run.get("status") or "") == "hard_failed" else ("warning" if str(run.get("status") or "") == "soft_failed" else "info") + events.append( + self.repository.save_production_launch_event( + { + "launch_event_id": self._event_id(launch_wave, "preflight_run", str(run.get("preflight_run_id") or "")), + "launch_wave": launch_wave, + "account_id": None, + "event_category": "preflight", + "event_type": "preflight_run", + "phase": phase, + "severity": severity, + "related_object_type": "production_preflight_run", + "related_object_id": run.get("preflight_run_id"), + "occurred_at": run.get("updated_at"), + "event_payload": {"status": run.get("status"), "go_no_go": run.get("go_no_go")}, + } + ) + ) + for check in preflight_checks_by_run.get(run["preflight_run_id"], []): + events.append( + self.repository.save_production_launch_event( + { + "launch_event_id": self._event_id(launch_wave, "preflight_check", str(check.get("preflight_check_id") or "")), + "launch_wave": launch_wave, + "account_id": None, + "event_category": "preflight_check", + "event_type": check.get("check_key"), + "phase": "go_no_go", + "severity": "critical" if str(check.get("status") or "") == "hard_failed" else ("warning" if str(check.get("status") or "") == "soft_failed" else "info"), + "related_object_type": "production_preflight_check", + "related_object_id": check.get("preflight_check_id"), + "occurred_at": check.get("created_at"), + "event_payload": {"status": check.get("status"), "linked_signoff_item_code": check.get("linked_signoff_item_code")}, + } + ) + ) + for panel_key in ("billing_anomaly_panel", "support_urgency_panel", "dispute_anomaly_panel", "dunning_anomaly_panel", "webhook_anomaly_panel"): + panel = command_center.get(panel_key) or {} + for alert in list(panel.get("alerts") or []): + events.append( + self.repository.save_production_launch_event( + { + "launch_event_id": self._event_id(launch_wave, "alert", str(alert.get("alert_key") or ""), "/".join(sorted(str(x) for x in alert.get("account_ids") or []))), + "launch_wave": launch_wave, + "account_id": next(iter(alert.get("account_ids") or []), None), + "event_category": "alert", + "event_type": alert.get("alert_key"), + "phase": "week_1", + "severity": alert.get("severity") or "warning", + "related_object_type": "launch_week_alert", + "related_object_id": alert.get("alert_key"), + "occurred_at": self._utcnow(), + "event_payload": {"count": alert.get("count"), "owner_role": alert.get("owner_role")}, + } + ) + ) + return { + "launch_wave": launch_wave, + "event_count": len(events), + "events": events, + } + + def list_events(self, *, launch_wave: Optional[str] = None, limit: int = 200) -> Dict[str, Any]: + events = self.repository.list_production_launch_events(launch_wave=launch_wave, limit=limit) + return { + "events": events, + "summary": { + "event_count": len(events), + "severity_counts": dict(Counter(str(item.get("severity") or "unknown") for item in events)), + "phase_counts": dict(Counter(str(item.get("phase") or "unknown") for item in events)), + }, + } + + def build_postmortem_pack(self, *, launch_wave: str, account_id: Optional[str] = None) -> Dict[str, Any]: + events = self.repository.list_production_launch_events(launch_wave=launch_wave, account_id=account_id, limit=500) + summary = { + "launch_wave": launch_wave, + "account_id": account_id, + "event_count": len(events), + "severity_counts": dict(Counter(str(item.get("severity") or "unknown") for item in events)), + "phase_counts": dict(Counter(str(item.get("phase") or "unknown") for item in events)), + } + postmortem = self.repository.save_production_postmortem_record( + { + "launch_wave": launch_wave, + "account_id": account_id, + "status": "draft", + "summary": summary, + } + ) + run_id = f"production_postmortem_pack_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}" + bundle_dir = self._artifacts_root() / "production_postmortem_pack" / run_id + bundle_dir.mkdir(parents=True, exist_ok=True) + timeline_lines = ["# Launch Timeline", ""] + [f"- {item['occurred_at']}: {item['event_category']} / {item['event_type']} / {item['severity']}" for item in events] + incident_lines = ["# Incident Chronology", ""] + [f"- {item['occurred_at']}: {item['event_type']} -> {item['event_payload_json']}" for item in events if str(item.get("severity") or "") in {"warning", "critical"}] + template_lines = [ + "# Postmortem Template", + "", + "## Summary", + "- what happened", + "- customer impact", + "- revenue impact", + "", + "## Timeline", + "- signoff to go-live", + "- incidents and recovery steps", + "", + "## Actions", + "- blockers to close", + "- runbook changes", + "- monitoring changes", + ] + rows = [ + { + "occurred_at": item.get("occurred_at"), + "event_category": item.get("event_category"), + "event_type": item.get("event_type"), + "severity": item.get("severity"), + "phase": item.get("phase"), + "related_object_type": item.get("related_object_type"), + "related_object_id": item.get("related_object_id"), + } + for item in events + if str(item.get("severity") or "") in {"warning", "critical"} + ] + self._write_text(bundle_dir / "launch_timeline.md", "\n".join(timeline_lines)) + self._write_text(bundle_dir / "incident_chronology.md", "\n".join(incident_lines)) + self._write_text(bundle_dir / "postmortem_template.md", "\n".join(template_lines)) + self._write_csv( + bundle_dir / "blocker_delay_recovery_capture.csv", + rows=rows, + fieldnames=["occurred_at", "event_category", "event_type", "severity", "phase", "related_object_type", "related_object_id"], + ) + self._write_json(bundle_dir / "summary.json", {"postmortem_record": postmortem, "summary": summary}) + latest_dir = self._artifacts_root() / "production_postmortem_pack" / "latest" + if latest_dir.exists(): + shutil.rmtree(latest_dir) + shutil.copytree(bundle_dir, latest_dir) + return { + "postmortem_record": postmortem, + "artifact_refs": { + "bundle_dir": str(bundle_dir), + "summary_json": str(bundle_dir / "summary.json"), + "launch_timeline_md": str(bundle_dir / "launch_timeline.md"), + "incident_chronology_md": str(bundle_dir / "incident_chronology.md"), + "blocker_delay_recovery_capture_csv": str(bundle_dir / "blocker_delay_recovery_capture.csv"), + "postmortem_template_md": str(bundle_dir / "postmortem_template.md"), + }, + } diff --git a/src/narrativeos/services/production_launch_week_pack.py b/src/narrativeos/services/production_launch_week_pack.py new file mode 100644 index 0000000..8cc09f9 --- /dev/null +++ b/src/narrativeos/services/production_launch_week_pack.py @@ -0,0 +1,465 @@ +from __future__ import annotations + +import csv +import json +import shutil +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from .production_acceptance import ProductionAcceptanceService +from .production_signoff import ProductionSignoffService + +if TYPE_CHECKING: + from .ops_commercialization_dashboard import OpsCommercializationDashboardService + + +ROOT = Path(__file__).resolve().parents[3] + +ALERT_THRESHOLD_ENV_MAP = { + "payment_failures": "NARRATIVEOS_LAUNCH_ALERT_PAYMENT_FAILURE_THRESHOLD", + "checkout_failures": "NARRATIVEOS_LAUNCH_ALERT_CHECKOUT_FAILURE_THRESHOLD", + "remote_generation_failures": "NARRATIVEOS_LAUNCH_ALERT_REMOTE_GENERATION_FAILURE_THRESHOLD", + "quality_blocks": "NARRATIVEOS_LAUNCH_ALERT_QUALITY_BLOCK_THRESHOLD", + "vercel_cold_start_latency": "NARRATIVEOS_LAUNCH_ALERT_COLD_START_P95_MS / NARRATIVEOS_LAUNCH_ALERT_COLD_START_5XX_THRESHOLD", + "webhook_failures": "NARRATIVEOS_LAUNCH_ALERT_WEBHOOK_FAILURE_THRESHOLD", + "invoice_issuance_failures": "NARRATIVEOS_LAUNCH_ALERT_INVOICE_FAILURE_THRESHOLD", + "dunning_spikes": "NARRATIVEOS_LAUNCH_ALERT_DUNNING_SPIKE_THRESHOLD", + "dispute_spikes": "NARRATIVEOS_LAUNCH_ALERT_DISPUTE_SPIKE_THRESHOLD", + "support_backlog": "NARRATIVEOS_LAUNCH_ALERT_SUPPORT_BACKLOG_THRESHOLD", + "renewal_due_no_action": "NARRATIVEOS_LAUNCH_ALERT_RENEWAL_NO_ACTION_THRESHOLD", + "overage_without_upgrade_follow_up": "NARRATIVEOS_LAUNCH_ALERT_OVERAGE_NO_UPGRADE_THRESHOLD", +} + +INCIDENT_ESCALATION_RULES = { + "payment_failures": { + "when_to_escalate": "支付失败数量超过阈值或同一 account 重复失败", + "escalation_owner": "stripe_owner", + "rollback_trigger": "大面积支付失败且 retry 无法恢复", + "required_evidence": ["payment transaction", "customer invoice detail", "dunning state"], + }, + "checkout_failures": { + "when_to_escalate": "邀请制试点 checkout 创建、过期或支付完成链路失败", + "escalation_owner": "stripe_owner", + "rollback_trigger": "checkout 无法完成导致付费用户无法恢复权益", + "required_evidence": ["checkout session", "payment transaction", "provider reconcile result"], + }, + "remote_generation_failures": { + "when_to_escalate": "正式域名 Reader 生成 job failed、stale running 或 runtime receipt 出现 provider/backend incident", + "escalation_owner": "infra_owner", + "rollback_trigger": "Reader continue / import / choice 在 serverless 环境持续不可恢复", + "required_evidence": ["reader generation job", "runtime receipt", "resume/retry outcome"], + }, + "quality_blocks": { + "when_to_escalate": "Reader/Author 质量门 blocked,或同一 world/account 重复触发", + "escalation_owner": "quality_owner", + "rollback_trigger": "质量阻断开始影响试点用户连续阅读或提交", + "required_evidence": ["quality event", "reason codes", "world/session trace"], + }, + "vercel_cold_start_latency": { + "when_to_escalate": "正式域名 cold-start p95 超过阈值或出现 5xx", + "escalation_owner": "infra_owner", + "rollback_trigger": "冷启动导致 Reader/checkout/Ops 关键路径频繁失败", + "required_evidence": ["remote performance artifact", "deployment id", "endpoint timing"], + }, + "webhook_failures": { + "when_to_escalate": "provider webhook 未 processed 或 replay 失败", + "escalation_owner": "infra_owner", + "rollback_trigger": "关键 webhook 持续积压导致状态无法收敛", + "required_evidence": ["provider webhook event", "webhook replay result", "invoice/payment state"], + }, + "invoice_issuance_failures": { + "when_to_escalate": "issued invoice 缺 hosted/pdf link 或状态异常", + "escalation_owner": "stripe_owner", + "rollback_trigger": "正式 invoice 无法稳定签发", + "required_evidence": ["invoice preview", "issued invoice payload", "provider invoice ref"], + }, + "dunning_spikes": { + "when_to_escalate": "open dunning run 超过阈值", + "escalation_owner": "support_finance_owner", + "rollback_trigger": "launch 后短时间大面积进入催缴", + "required_evidence": ["dunning run", "invoice status", "customer workspace"], + }, + "dispute_spikes": { + "when_to_escalate": "未结 dispute 超过阈值", + "escalation_owner": "support_finance_owner", + "rollback_trigger": "同一波次客户 dispute 快速增多", + "required_evidence": ["dispute record", "billable event", "manual adjustment/refund state"], + }, + "support_backlog": { + "when_to_escalate": "open/in_progress support cases 超过阈值", + "escalation_owner": "support_finance_owner", + "rollback_trigger": "launch week 支持积压影响 SLA", + "required_evidence": ["support case", "account workspace", "owner assignment"], + }, + "renewal_due_no_action": { + "when_to_escalate": "renewal_due 账户未挂 dunning/upgrade 动作", + "escalation_owner": "stripe_owner", + "rollback_trigger": "关键首批客户临近续费但无动作", + "required_evidence": ["customer account", "renewal summary", "launch wave status"], + }, + "overage_without_upgrade_follow_up": { + "when_to_escalate": "recommended upgrade 长时间无 follow-up", + "escalation_owner": "support_finance_owner", + "rollback_trigger": "overage 持续增长但扩容无跟进", + "required_evidence": ["overage flag", "expansion candidate", "customer workspace"], + }, +} + + +class ProductionLaunchWeekPackService: + def __init__( + self, + *, + production_signoff_service: ProductionSignoffService, + production_acceptance_service: ProductionAcceptanceService, + base_dir: Optional[Path] = None, + ) -> None: + self.production_signoff = production_signoff_service + self.production_acceptance = production_acceptance_service + self.base_dir = Path(base_dir or ROOT) + self.dashboard_service: Optional["OpsCommercializationDashboardService"] = None + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _artifacts_root(self) -> Path: + return self.base_dir / "artifacts" + + def _load_json_artifact(self, relative_path: str) -> Dict[str, Any]: + path = self.base_dir / relative_path + if not path.exists(): + raise FileNotFoundError(f"missing_artifact:{relative_path}") + return json.loads(path.read_text(encoding="utf-8")) + + def _latest_go_live_checklist(self) -> Dict[str, Any]: + return self._load_json_artifact("artifacts/production_go_live_checklist/latest/go_live_checklist.json") + + def _latest_manual_signoff(self) -> Dict[str, Any]: + return self._load_json_artifact("artifacts/production_manual_signoff/latest/manual_signoff_sheet.json") + + def _latest_cutover_pack_summary(self) -> Dict[str, Any]: + return self._load_json_artifact("artifacts/production_cutover_pack/latest/summary.json") + + def _latest_delivery_manifest(self) -> Dict[str, Any]: + return self._load_json_artifact("artifacts/commercial_delivery_bundle/latest/bundle_manifest.json") + + def _latest_pack_dir(self) -> Path: + return self._artifacts_root() / "production_launch_week_pack" / "latest" + + def _write_text(self, path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content.rstrip() + "\n", encoding="utf-8") + + def _write_csv(self, path: Path, *, rows: List[Dict[str, Any]], fieldnames: List[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as fh: + writer = csv.DictWriter(fh, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow(row) + + def _sha256(self, path: Path) -> str: + import hashlib + + digest = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + def _zip_bundle(self, bundle_dir: Path) -> Path: + zip_path = bundle_dir.parent / f"{bundle_dir.name}.zip" + with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for path in sorted(bundle_dir.rglob("*")): + if path.is_file(): + zf.write(path, path.relative_to(bundle_dir)) + return zip_path + + def _current_context(self) -> Dict[str, Any]: + signoff_summary = self.production_signoff.current_signoff_summary() + signoff_detail = None + if signoff_summary and signoff_summary.get("signoff_id"): + try: + signoff_detail = self.production_signoff.signoff_detail(signoff_id=signoff_summary["signoff_id"]) + except KeyError: + signoff_detail = None + acceptance_listing = self.production_acceptance.list_acceptance_records(limit=100) + launch_week_pack = None + if self.dashboard_service is not None: + launch_week_pack = self.dashboard_service.summary(limit=50).get("launch_week_alert_pack") + return { + "go_live_checklist": self._latest_go_live_checklist(), + "manual_signoff": self._latest_manual_signoff(), + "cutover_summary": self._latest_cutover_pack_summary(), + "delivery_manifest": self._latest_delivery_manifest(), + "signoff_summary": signoff_summary or {}, + "signoff_detail": signoff_detail or {}, + "acceptance_listing": acceptance_listing, + "launch_week_alert_pack": launch_week_pack or {"summary": {"alert_count": 0}, "alerts": []}, + } + + def current_pack_summary( + self, + *, + signoff_summary: Optional[Dict[str, Any]] = None, + acceptance_summary: Optional[Dict[str, Any]] = None, + launch_week_alert_pack: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + latest_dir = self._latest_pack_dir() + summary_path = latest_dir / "summary.json" + manifest_path = latest_dir / "manifest.json" + if summary_path.exists(): + summary = json.loads(summary_path.read_text(encoding="utf-8")) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) if manifest_path.exists() else {} + return { + "status": "generated", + "bundle_id": summary.get("bundle_id"), + "generated_at": summary.get("generated_at"), + "document_count": summary.get("document_count"), + "unresolved_manual_signoff_count": summary.get("unresolved_manual_signoff_count"), + "launch_week_alert_count": summary.get("launch_week_alert_count"), + "launch_wave_count": summary.get("launch_wave_count"), + "refs": summary.get("refs") or {}, + "manifest": manifest, + } + unresolved_manual = len( + [item for item in list(self._latest_manual_signoff().get("items") or []) if str(item.get("status") or "") == "pending_manual_signoff"] + ) + return { + "status": "not_generated", + "bundle_id": None, + "generated_at": None, + "document_count": 6, + "unresolved_manual_signoff_count": unresolved_manual, + "launch_week_alert_count": int((launch_week_alert_pack or {}).get("summary", {}).get("alert_count") or 0), + "launch_wave_count": int((acceptance_summary or {}).get("launch_wave_count") or 0), + "refs": {}, + "manifest": {}, + } + + def latest_pack(self) -> Dict[str, Any]: + latest_dir = self._latest_pack_dir() + summary = self.current_pack_summary() + return { + "summary": summary, + "latest_dir": str(latest_dir) if latest_dir.exists() else None, + "doc_refs": summary.get("refs", {}).get("doc_refs", []), + } + + def _launch_day_checklist(self, context: Dict[str, Any]) -> str: + signoff_detail = context["signoff_detail"] or {} + cutover_summary = context["cutover_summary"] or {} + pending_items = [item for item in list(signoff_detail.get("items") or []) if item.get("status") == "pending"] + lines = [ + "# Launch Day Checklist", + "", + "## T-24h", + f"- Review unresolved production signoff items: {len(pending_items)} pending", + f"- Confirm cutover pack overall health: {cutover_summary.get('overall_health')}", + "- Confirm first-customer acceptance status and launch wave assignment", + "", + "## T-2h", + "- Re-run stripe connectivity, webhook health, invoice issuance, payment sync, and customer workspace smoke checks", + "- Confirm no critical launch-week alerts remain unresolved", + "- Confirm planned cutover window and rollback owner are visible in canonical signoff", + "", + "## T-0", + "- Mark launch wave status to active when operator decision is made", + "- Start live monitoring of Reader generation, checkout, quality blocks, Vercel cold-start, support, and dispute signals", + "", + "## T+1h", + "- Review Launch Week Alert Pack for new high/critical signals", + "- Confirm launch-week remote monitoring remains ready_to_expand before issuing more invites", + "- Confirm first-customer workspace still reflects invoice / dunning / upgrade posture", + "", + "## T+4h", + "- Review dunning spikes, support backlog, dispute spikes, and overage follow-up", + "- Attach any material operational evidence to production signoff items", + "", + "## T+24h", + "- Review finance reconciliation sheet", + "- Confirm support, finance, and on-call owners have no unresolved handoff gaps", + ] + return "\n".join(lines) + + def _day_1_monitoring_rows(self, context: Dict[str, Any]) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + for alert in list((context["launch_week_alert_pack"] or {}).get("alerts") or []): + rows.append( + { + "alert_key": alert.get("alert_key"), + "owner_role": alert.get("owner_role"), + "threshold_source": ALERT_THRESHOLD_ENV_MAP.get(str(alert.get("alert_key") or ""), "hardcoded"), + "escalation_action": " / ".join(alert.get("recommended_actions") or []), + "drilldown_refs": " / ".join(f"{item.get('kind')}:{item.get('id')}" for item in alert.get("drilldown_refs") or []), + } + ) + return rows + + def _day_1_monitoring_sheet(self, context: Dict[str, Any]) -> str: + rows = self._day_1_monitoring_rows(context) + lines = [ + "# Day 1 Monitoring Sheet", + "", + "| alert_key | owner_role | threshold_source | escalation_action | drilldown_refs |", + "| --- | --- | --- | --- | --- |", + ] + for row in rows: + lines.append( + f"| {row['alert_key'] or '-'} | {row['owner_role'] or '-'} | {row['threshold_source'] or '-'} | {row['escalation_action'] or '-'} | {row['drilldown_refs'] or '-'} |" + ) + return "\n".join(lines) + + def _week_1_ops_board(self, context: Dict[str, Any]) -> str: + signoff_summary = context["signoff_summary"] or {} + acceptance_summary = context["acceptance_listing"].get("summary") or {} + alert_summary = (context["launch_week_alert_pack"] or {}).get("summary") or {} + lines = [ + "# Week 1 Ops Board", + "", + "## Day 1", + f"- production signoff status: {signoff_summary.get('status') or '-'}", + f"- launch-week alert count: {alert_summary.get('alert_count') or 0}", + f"- go_live_ready accounts: {acceptance_summary.get('go_live_ready_count') or 0}", + "", + "## Day 2-3", + f"- blocked go-live accounts: {acceptance_summary.get('blocked_go_live_count') or 0}", + f"- launch waves: {acceptance_summary.get('launch_wave_count') or 0}", + f"- unresolved manual signoff: {len([item for item in list(context['manual_signoff'].get('items') or []) if item.get('status') == 'pending_manual_signoff'])}", + "", + "## Day 4-7", + "- review finance reconciliation, support backlog, dispute trends, and expansion follow-up", + "- confirm no launch-week alert remains without owner action", + ] + return "\n".join(lines) + + def _support_triage_matrix(self) -> str: + rows = [ + ("support_case", "medium", "support_finance_owner", "support -> ops commercialization -> governance if needed", "same business day"), + ("dispute", "medium", "support_finance_owner", "support -> finance -> manual_adjustment/refund", "same business day"), + ("governance_case", "high", "security_owner", "support -> governance -> reviewer owner", "same day"), + ("payment_failure_customer_report", "high", "stripe_owner", "support -> stripe_owner -> dunning follow-up", "same hour"), + ] + lines = [ + "# Support Triage Matrix", + "", + "| issue_type | severity | primary_owner_role | escalation_path | sla_expectation |", + "| --- | --- | --- | --- | --- |", + ] + for row in rows: + lines.append(f"| {row[0]} | {row[1]} | {row[2]} | {row[3]} | {row[4]} |") + return "\n".join(lines) + + def _incident_escalation_matrix(self) -> str: + lines = [ + "# Incident Escalation Matrix", + "", + "| alert_key | when_to_escalate | escalation_owner | rollback_trigger | required_evidence |", + "| --- | --- | --- | --- | --- |", + ] + for key, rule in INCIDENT_ESCALATION_RULES.items(): + lines.append( + f"| {key} | {rule['when_to_escalate']} | {rule['escalation_owner']} | {rule['rollback_trigger']} | {' / '.join(rule['required_evidence'])} |" + ) + return "\n".join(lines) + + def _finance_reconciliation_sheet(self, context: Dict[str, Any]) -> str: + cutover_summary = context["cutover_summary"] or {} + checks = cutover_summary.get("checks") or {} + invoice = checks.get("invoice_issuance_smoke") or {} + payment = checks.get("payment_sync_smoke") or {} + lines = [ + "# Finance Reconciliation Sheet", + "", + f"- invoice preview due: {(invoice.get('invoice_preview') or {}).get('total_due_usd') or 0}", + f"- issued invoice status: {(invoice.get('issued_invoice') or {}).get('status') or '-'}", + f"- failed invoice observed: {(payment.get('failed_invoice') or {}).get('status') or '-'}", + f"- paid invoice observed: {(payment.get('paid_invoice') or {}).get('status') or '-'}", + f"- dunning after recovery: {(payment.get('lifecycle_after_recovery') or {}).get('dunning_summary', {}).get('status') or '-'}", + "", + "## Checklist", + "- compare invoice preview amount to issued invoice amount", + "- compare canonical invoice status to provider payment status", + "- review dunning state against payment failure/recovery", + "- review disputes and manual adjustments before close of day", + ] + return "\n".join(lines) + + def build_pack(self, output_root: str | Path | None = None) -> Dict[str, Any]: + context = self._current_context() + run_id = f"production_launch_week_pack_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}" + bundle_dir = Path(output_root) if output_root else (self._artifacts_root() / "production_launch_week_pack" / run_id) + docs_dir = bundle_dir / "docs" + bundle_dir.mkdir(parents=True, exist_ok=True) + docs_dir.mkdir(parents=True, exist_ok=True) + + doc_payloads = { + "launch_day_checklist.md": self._launch_day_checklist(context), + "day_1_monitoring_sheet.md": self._day_1_monitoring_sheet(context), + "week_1_ops_board.md": self._week_1_ops_board(context), + "support_triage_matrix.md": self._support_triage_matrix(), + "incident_escalation_matrix.md": self._incident_escalation_matrix(), + "finance_reconciliation_sheet.md": self._finance_reconciliation_sheet(context), + } + for name, content in doc_payloads.items(): + self._write_text(docs_dir / name, content) + + unresolved_manual = len([item for item in list(context["manual_signoff"].get("items") or []) if item.get("status") == "pending_manual_signoff"]) + launch_alert_count = int((context["launch_week_alert_pack"] or {}).get("summary", {}).get("alert_count") or 0) + acceptance_summary = context["acceptance_listing"].get("summary") or {} + summary = { + "bundle_id": run_id, + "generated_at": self._utcnow(), + "document_count": len(doc_payloads), + "unresolved_manual_signoff_count": unresolved_manual, + "launch_week_alert_count": launch_alert_count, + "launch_wave_count": acceptance_summary.get("launch_wave_count") or 0, + "refs": { + "doc_refs": [f"docs/{name}" for name in doc_payloads], + "source_refs": { + "go_live_checklist": "artifacts/production_go_live_checklist/latest/go_live_checklist.json", + "manual_signoff": "artifacts/production_manual_signoff/latest/manual_signoff_sheet.json", + "cutover_pack": "artifacts/production_cutover_pack/latest/summary.json", + "delivery_bundle": "artifacts/commercial_delivery_bundle/latest/bundle_manifest.json", + }, + }, + } + (bundle_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + manifest = { + "bundle_id": run_id, + "generated_at": self._utcnow(), + "included_files": [ + { + "path": str(path.relative_to(bundle_dir)), + "size_bytes": path.stat().st_size, + "sha256": self._sha256(path), + } + for path in sorted(bundle_dir.rglob("*")) + if path.is_file() + ], + } + (bundle_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + (bundle_dir / "README.md").write_text( + "\n".join( + [ + "# Production Launch Week Ops Pack", + "", + "This pack contains launch-week operational docs derived from current signoff, cutover, acceptance, and commercialization evidence.", + ] + ), + encoding="utf-8", + ) + zip_path = self._zip_bundle(bundle_dir) + latest_dir = self._artifacts_root() / "production_launch_week_pack" / "latest" + if latest_dir.exists(): + shutil.rmtree(latest_dir) + shutil.copytree(bundle_dir, latest_dir) + return { + "bundle_id": run_id, + "bundle_dir": str(bundle_dir), + "latest_dir": str(latest_dir), + "zip_path": str(zip_path), + "summary": summary, + } diff --git a/src/narrativeos/services/production_manual_signoff.py b/src/narrativeos/services/production_manual_signoff.py new file mode 100644 index 0000000..9c19173 --- /dev/null +++ b/src/narrativeos/services/production_manual_signoff.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import csv +import json +import shutil +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List + + +ROOT = Path(__file__).resolve().parents[3] + + +def _utcnow() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _run_id() -> str: + return f"production_manual_signoff_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}" + + +def _load_json(path: Path) -> Dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _sha256(path: Path) -> str: + import hashlib + + digest = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _manual_items(go_live: Dict[str, Any]) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + for item in list(go_live.get("items") or []): + if item.get("status") != "manual_confirm": + continue + prompt = { + "billing_005": "Confirm live Stripe secret/publishable keys are configured in the production secret manager and the deployment environment points at them.", + "webhook_001": "Confirm the production webhook endpoint is registered in Stripe, reachable over HTTPS, and the production signing secret matches the server config.", + "security_003": "Confirm production log drains, customer-safe retention policy, and access boundaries are reviewed by the security/ops owner.", + "operations_003": "Confirm named on-call owner, finance owner, and support owner are assigned for launch week.", + "deploy_002": "Confirm production Postgres backup/restore tooling and operator access are available and tested.", + }.get(item.get("item_id") or "", item.get("notes") or "") + rows.append( + { + "item_id": item.get("item_id"), + "category": item.get("category"), + "label": item.get("label"), + "evidence": item.get("evidence"), + "prompt": prompt, + "owner": "", + "status": "pending_manual_signoff", + "decision": "", + "decision_at": "", + "notes": item.get("notes") or "", + } + ) + return rows + + +def _write_markdown(path: Path, *, worksheet: List[Dict[str, Any]], go_live_summary: Dict[str, Any]) -> None: + lines = [ + "# Production Manual Signoff Walk-Through", + "", + f"- generated_at: {_utcnow()}", + f"- source_checklist: {go_live_summary.get('checklist_id')}", + f"- manual_items: {len(worksheet)}", + "", + "Use this worksheet to complete the remaining production-only confirmations.", + "", + ] + for item in worksheet: + lines.extend( + [ + f"## {item['item_id']} — {item['label']}", + f"- category: {item['category']}", + f"- evidence: {item['evidence']}", + f"- prompt: {item['prompt']}", + "- owner: ", + "- decision: pending / approved / rejected", + "- decision_at: ", + "- notes: ", + "", + ] + ) + path.write_text("\n".join(lines), encoding="utf-8") + + +def _write_csv(path: Path, worksheet: List[Dict[str, Any]]) -> None: + with path.open("w", encoding="utf-8", newline="") as fh: + writer = csv.DictWriter( + fh, + fieldnames=["item_id", "category", "label", "evidence", "prompt", "owner", "status", "decision", "decision_at", "notes"], + ) + writer.writeheader() + for item in worksheet: + writer.writerow(item) + + +def _zip_bundle(bundle_dir: Path) -> Path: + zip_path = bundle_dir.parent / f"{bundle_dir.name}.zip" + with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for path in sorted(bundle_dir.rglob("*")): + if path.is_file(): + zf.write(path, path.relative_to(bundle_dir)) + return zip_path + + +def build_production_manual_signoff_bundle(output_root: str | Path | None = None) -> Dict[str, Any]: + run_id = _run_id() + bundle_dir = Path(output_root) if output_root else (ROOT / "artifacts" / "production_manual_signoff" / run_id) + bundle_dir.mkdir(parents=True, exist_ok=True) + + go_live_summary = _load_json(ROOT / "artifacts/production_go_live_checklist/latest/go_live_checklist.json") + worksheet = _manual_items(go_live_summary) + + (bundle_dir / "manual_signoff_sheet.json").write_text(json.dumps({"items": worksheet}, ensure_ascii=False, indent=2), encoding="utf-8") + _write_markdown(bundle_dir / "manual_signoff_walkthrough.md", worksheet=worksheet, go_live_summary=go_live_summary) + _write_csv(bundle_dir / "manual_signoff_sheet.csv", worksheet) + (bundle_dir / "README.md").write_text( + "\n".join( + [ + "# Production Manual Signoff Walk-Through", + "", + "- `manual_signoff_walkthrough.md` is the operator walkthrough version", + "- `manual_signoff_sheet.csv` is the editable spreadsheet-friendly version", + "- `manual_signoff_sheet.json` is the machine-readable version", + "", + "This bundle only contains the remaining production-only signoff items.", + ] + ), + encoding="utf-8", + ) + + manifest = { + "generated_at": _utcnow(), + "source_checklist_id": go_live_summary.get("checklist_id"), + "manual_item_count": len(worksheet), + "items": [item["item_id"] for item in worksheet], + "included_files": [ + { + "path": str(path.relative_to(bundle_dir)), + "size_bytes": path.stat().st_size, + "sha256": _sha256(path), + } + for path in sorted(bundle_dir.rglob("*")) + if path.is_file() + ], + } + (bundle_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + zip_path = _zip_bundle(bundle_dir) + + latest_dir = bundle_dir.parent / "latest" + if latest_dir.exists(): + shutil.rmtree(latest_dir) + shutil.copytree(bundle_dir, latest_dir) + + return { + "bundle_id": bundle_dir.name, + "bundle_dir": str(bundle_dir), + "latest_dir": str(latest_dir), + "zip_path": str(zip_path), + "manual_item_count": len(worksheet), + } diff --git a/src/narrativeos/services/production_preflight.py b/src/narrativeos/services/production_preflight.py new file mode 100644 index 0000000..66cb709 --- /dev/null +++ b/src/narrativeos/services/production_preflight.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import json +import shutil +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +from .production_cutover_checks import ( + run_audit_logging_smoke, + run_backup_restore_verification_hooks, + run_customer_workspace_smoke, + run_invoice_issuance_smoke, + run_payment_sync_smoke, + run_stripe_connectivity_check, + run_support_routing_smoke, + run_webhook_health_check, +) +from .production_signoff import ProductionSignoffService +from ..persistence.repositories import SQLAlchemyPlatformRepository + + +ROOT = Path(__file__).resolve().parents[3] + +CHECK_SPECS = [ + {"check_key": "stripe_connectivity", "owner_role": "stripe_owner", "linked_signoff_item_code": "billing_005", "classification": "hard", "runner": run_stripe_connectivity_check}, + {"check_key": "webhook_health", "owner_role": "infra_owner", "linked_signoff_item_code": "webhook_001", "classification": "hard", "runner": run_webhook_health_check}, + {"check_key": "invoice_issuance_smoke", "owner_role": "stripe_owner", "linked_signoff_item_code": "billing_005", "classification": "hard", "runner": run_invoice_issuance_smoke}, + {"check_key": "payment_sync_smoke", "owner_role": "infra_owner", "linked_signoff_item_code": "webhook_001", "classification": "hard", "runner": run_payment_sync_smoke}, + {"check_key": "customer_workspace_smoke", "owner_role": "ops_reviewer", "linked_signoff_item_code": None, "classification": "soft", "runner": run_customer_workspace_smoke}, + {"check_key": "support_routing_smoke", "owner_role": "support_finance_owner", "linked_signoff_item_code": "operations_003", "classification": "soft", "runner": run_support_routing_smoke}, + {"check_key": "audit_logging_smoke", "owner_role": "security_owner", "linked_signoff_item_code": "security_003", "classification": "soft", "runner": run_audit_logging_smoke}, + {"check_key": "backup_restore_readiness_hooks", "owner_role": "db_owner", "linked_signoff_item_code": "deploy_002", "classification": "hard", "runner": run_backup_restore_verification_hooks}, +] + + +class ProductionPreflightService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + production_signoff_service: ProductionSignoffService, + base_dir: Optional[Path] = None, + ) -> None: + self.repository = repository + self.production_signoff = production_signoff_service + self.base_dir = Path(base_dir or ROOT) + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _artifacts_root(self) -> Path: + return self.base_dir / "artifacts" + + def _write_json(self, 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 _write_text(self, path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content.rstrip() + "\n", encoding="utf-8") + + def _current_signoff_id(self, signoff_id: Optional[str]) -> Optional[str]: + if signoff_id: + return signoff_id + current = self.production_signoff.current_signoff_summary() + return (current or {}).get("signoff_id") + + def _append_signoff_evidence_if_linked(self, *, signoff_id: Optional[str], check_row: Dict[str, Any], evidence_ref: str) -> None: + if not signoff_id or not check_row.get("linked_signoff_item_code"): + return + detail = self.production_signoff.signoff_detail(signoff_id=signoff_id) + item = next((row for row in detail.get("items") or [] if row.get("item_code") == check_row["linked_signoff_item_code"]), None) + if not item: + return + self.production_signoff.append_signoff_evidence( + actor_id="production_preflight_runner", + actor_role="ops", + signoff_item_id=item["signoff_item_id"], + evidence_type="artifact_ref", + summary=f"preflight:{check_row['check_key']}:{check_row['status']}", + source_ref={"path": evidence_ref}, + payload={"preflight_run_id": check_row["preflight_run_id"], "check_key": check_row["check_key"], "status": check_row["status"]}, + customer_safe=False, + ) + + def run_preflight( + self, + *, + actor_id: str, + actor_role: str, + launch_wave: str = "wave_1", + signoff_id: Optional[str] = None, + target_environment: str = "production", + output_root: str | Path | None = None, + ) -> Dict[str, Any]: + resolved_signoff_id = self._current_signoff_id(signoff_id) + run = self.repository.save_production_preflight_run( + { + "signoff_id": resolved_signoff_id, + "launch_wave": launch_wave, + "target_environment": target_environment, + "status": "running", + "go_no_go": "manual_review", + "run_payload": {"requested_by": actor_id, "requested_role": actor_role}, + } + ) + bundle_dir = Path(output_root) if output_root else (self._artifacts_root() / "production_preflight_runs" / run["preflight_run_id"]) + checks_dir = bundle_dir / "checks" + bundle_dir.mkdir(parents=True, exist_ok=True) + checks_dir.mkdir(parents=True, exist_ok=True) + + persisted_checks: List[Dict[str, Any]] = [] + hard_fail_count = 0 + soft_fail_count = 0 + for spec in CHECK_SPECS: + runner: Callable[..., Dict[str, Any]] = spec["runner"] + result = runner(output_root=checks_dir) + check_status = "passed" + if not bool(result.get("healthy")): + check_status = "hard_failed" if spec["classification"] == "hard" else "soft_failed" + if check_status == "hard_failed": + hard_fail_count += 1 + elif check_status == "soft_failed": + soft_fail_count += 1 + evidence_ref = f"checks/{spec['check_key']}.json" + check_row = self.repository.save_production_preflight_check( + { + "preflight_run_id": run["preflight_run_id"], + "check_key": spec["check_key"], + "linked_signoff_item_code": spec["linked_signoff_item_code"], + "owner_role": spec["owner_role"], + "status": check_status, + "summary": result.get("summary") or spec["check_key"], + "evidence_ref": evidence_ref, + "payload": result, + } + ) + persisted_checks.append(check_row) + self._append_signoff_evidence_if_linked(signoff_id=resolved_signoff_id, check_row=check_row, evidence_ref=evidence_ref) + + status = "passed" + go_no_go = "go" + if hard_fail_count > 0: + status = "hard_failed" + go_no_go = "no_go" + elif soft_fail_count > 0: + status = "soft_failed" + go_no_go = "manual_review" + updated_run = self.repository.save_production_preflight_run( + { + **run, + "status": status, + "go_no_go": go_no_go, + "hard_fail_count": hard_fail_count, + "soft_fail_count": soft_fail_count, + "run_payload_json": { + "requested_by": actor_id, + "requested_role": actor_role, + "checks_dir": str(checks_dir), + "artifact_dir": str(bundle_dir), + "check_count": len(persisted_checks), + }, + } + ) + report_refs = self._write_report(run=updated_run, checks=persisted_checks, bundle_dir=bundle_dir) + return { + "preflight_run": updated_run, + "checks": persisted_checks, + "report_refs": report_refs, + } + + def _write_report(self, *, run: Dict[str, Any], checks: List[Dict[str, Any]], bundle_dir: Path) -> Dict[str, Any]: + summary_payload = { + "preflight_run": run, + "checks": checks, + } + self._write_json(bundle_dir / "summary.json", summary_payload) + lines = [ + "# Production Preflight Report", + "", + f"- preflight_run_id: {run['preflight_run_id']}", + f"- signoff_id: {run.get('signoff_id') or '-'}", + f"- launch_wave: {run['launch_wave']}", + f"- target_environment: {run['target_environment']}", + f"- status: {run['status']}", + f"- go_no_go: {run['go_no_go']}", + f"- hard_fail_count: {run['hard_fail_count']}", + f"- soft_fail_count: {run['soft_fail_count']}", + "", + "## Checks", + ] + for check in checks: + lines.append(f"- {check['check_key']}: {check['status']} · owner {check['owner_role']} · linked {check.get('linked_signoff_item_code') or '-'}") + self._write_text(bundle_dir / "report.md", "\n".join(lines)) + return { + "artifact_dir": str(bundle_dir), + "summary_json": str(bundle_dir / "summary.json"), + "report_md": str(bundle_dir / "report.md"), + } + + def list_runs( + self, + *, + signoff_id: Optional[str] = None, + launch_wave: Optional[str] = None, + limit: int = 25, + ) -> Dict[str, Any]: + runs = self.repository.list_production_preflight_runs(signoff_id=signoff_id, launch_wave=launch_wave, limit=limit) + latest = runs[0] if runs else None + return { + "runs": runs, + "current_run": latest, + "summary": { + "run_count": len(runs), + "latest_run_id": (latest or {}).get("preflight_run_id"), + "status_counts": dict(Counter(str(item.get("status") or "unknown") for item in runs)), + }, + } + + def run_detail(self, *, preflight_run_id: str) -> Dict[str, Any]: + run = self.repository.get_production_preflight_run(preflight_run_id) + checks = self.repository.list_production_preflight_checks(preflight_run_id=preflight_run_id, limit=100) + artifact_dir = Path((run.get("run_payload_json") or {}).get("artifact_dir") or self._artifacts_root() / "production_preflight_runs" / preflight_run_id) + return { + "preflight_run": run, + "checks": checks, + "report_refs": { + "artifact_dir": str(artifact_dir), + "summary_json": str(artifact_dir / "summary.json"), + "report_md": str(artifact_dir / "report.md"), + }, + } + + def report(self, *, preflight_run_id: str) -> Dict[str, Any]: + detail = self.run_detail(preflight_run_id=preflight_run_id) + artifact_dir = Path(detail["report_refs"]["artifact_dir"]) + if not (artifact_dir / "summary.json").exists(): + self._write_report(run=detail["preflight_run"], checks=detail["checks"], bundle_dir=artifact_dir) + return detail["report_refs"] diff --git a/src/narrativeos/services/production_signoff.py b/src/narrativeos/services/production_signoff.py new file mode 100644 index 0000000..ae2c379 --- /dev/null +++ b/src/narrativeos/services/production_signoff.py @@ -0,0 +1,481 @@ +from __future__ import annotations + +import csv +import json +import shutil +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from .commercial_audit import CommercialAuditService +from ..persistence.repositories import SQLAlchemyPlatformRepository + + +DEFAULT_OWNER_ROLE_MAP = { + "billing_005": "stripe_owner", + "webhook_001": "infra_owner", + "security_003": "security_owner", + "operations_003": "support_finance_owner", + "deploy_002": "db_owner", +} + +APPROVED_ITEM_STATUSES = {"approved", "waived"} +PENDING_ITEM_STATUSES = {"pending", "ready_for_review"} +REJECTED_ITEM_STATUSES = {"rejected"} + + +class ProductionSignoffService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + audit_service: CommercialAuditService, + base_dir: Optional[Path] = None, + ) -> None: + self.repository = repository + self.audit = audit_service + self.base_dir = Path(base_dir or Path(__file__).resolve().parents[3]) + + def _utcnow(self) -> datetime: + return datetime.now(timezone.utc) + + def _utcnow_iso(self) -> str: + return self._utcnow().isoformat() + + def _parse_dt(self, value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + def _default_due_at(self, *, due_in_days: int) -> str: + return (self._utcnow() + timedelta(days=max(1, int(due_in_days or 1)))).isoformat() + + def _artifacts_root(self) -> Path: + return self.base_dir / "artifacts" + + def _load_json_artifact(self, relative_path: str) -> Dict[str, Any]: + path = self.base_dir / relative_path + if not path.exists(): + raise FileNotFoundError(f"missing_artifact:{relative_path}") + return json.loads(path.read_text(encoding="utf-8")) + + def _latest_go_live_checklist(self) -> Dict[str, Any]: + return self._load_json_artifact("artifacts/production_go_live_checklist/latest/go_live_checklist.json") + + def _latest_manual_signoff_sheet(self) -> Dict[str, Any]: + return self._load_json_artifact("artifacts/production_manual_signoff/latest/manual_signoff_sheet.json") + + def _next_due_item(self, items: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + pending = [item for item in items if str(item.get("status") or "") in PENDING_ITEM_STATUSES] + if not pending: + return None + pending.sort(key=lambda item: (self._parse_dt(item.get("due_at")) or datetime.max.replace(tzinfo=timezone.utc), str(item.get("item_code") or ""))) + return pending[0] + + def _rollup(self, items: List[Dict[str, Any]], cutover_windows: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]: + approved_count = sum(1 for item in items if str(item.get("status") or "") in APPROVED_ITEM_STATUSES) + pending_count = sum(1 for item in items if str(item.get("status") or "") in PENDING_ITEM_STATUSES) + rejected_count = sum(1 for item in items if str(item.get("status") or "") in REJECTED_ITEM_STATUSES) + total_count = len(items) + manual_items = [item for item in items if bool((item.get("item_payload_json") or {}).get("requires_manual_confirmation"))] + approved_manual_count = sum(1 for item in manual_items if str(item.get("status") or "") in APPROVED_ITEM_STATUSES) + next_due_item = self._next_due_item(items) + planned_cutover = None + if cutover_windows: + ordered = sorted( + cutover_windows, + key=lambda item: (self._parse_dt(item.get("starts_at")) or datetime.max.replace(tzinfo=timezone.utc), str(item.get("cutover_window_id") or "")), + ) + planned_cutover = ordered[0] + overall_status = "draft" + if rejected_count > 0: + overall_status = "blocked" + elif total_count == 0: + overall_status = "draft" + elif manual_items and approved_manual_count == len(manual_items): + overall_status = "fully_signed" + elif approved_manual_count > 0: + overall_status = "partially_signed" + else: + overall_status = "in_review" + return { + "status": overall_status, + "item_count": total_count, + "approved_item_count": approved_count, + "pending_item_count": pending_count, + "rejected_item_count": rejected_count, + "manual_item_count": len(manual_items), + "approved_manual_item_count": approved_manual_count, + "next_due_item": next_due_item, + "planned_cutover_window": planned_cutover, + } + + def _owner_role_for_code(self, item_code: str) -> str: + return DEFAULT_OWNER_ROLE_MAP.get(str(item_code or ""), "ops_owner") + + def _initialize_item_records( + self, + *, + signoff_id: str, + due_in_days: int, + ) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + checklist = self._latest_go_live_checklist() + manual_sheet = self._latest_manual_signoff_sheet() + manual_by_code = {str(item.get("item_id") or ""): dict(item or {}) for item in list(manual_sheet.get("items") or [])} + created_items: List[Dict[str, Any]] = [] + created_evidence: List[Dict[str, Any]] = [] + for source_item in list(checklist.get("items") or []): + item_code = str(source_item.get("item_id") or "") + manual_entry = manual_by_code.get(item_code, {}) + requires_manual = bool(source_item.get("requires_manual_confirmation")) + status = "pending" if requires_manual else "approved" + approved_at = None if requires_manual else self._utcnow_iso() + due_at = self._default_due_at(due_in_days=due_in_days) if requires_manual else None + item_payload = { + "source_status": source_item.get("status"), + "source_evidence": source_item.get("evidence"), + "source_notes": source_item.get("notes"), + "requires_manual_confirmation": requires_manual, + "manual_prompt": manual_entry.get("prompt"), + "manual_sheet_status": manual_entry.get("status"), + } + item = self.repository.save_production_signoff_item( + { + "signoff_id": signoff_id, + "item_code": item_code, + "category": source_item["category"], + "label": source_item["label"], + "owner_role": self._owner_role_for_code(item_code), + "owner_actor_id": None, + "due_at": due_at, + "status": status, + "decision_note": None if requires_manual else "seeded_from_go_live_checklist", + "approved_at": approved_at, + "evidence_count": 0, + "item_payload": item_payload, + } + ) + created_items.append(item) + evidence = self.repository.save_production_signoff_evidence( + { + "signoff_id": signoff_id, + "signoff_item_id": item["signoff_item_id"], + "evidence_type": "artifact_ref", + "source_ref": {"path": str(source_item.get("evidence") or "")}, + "summary": f"seeded:{item_code}", + "customer_safe": False, + "payload": {"source_item": source_item, "manual_entry": manual_entry}, + } + ) + created_evidence.append(evidence) + item = self.repository.save_production_signoff_item({**item, "item_payload_json": item["item_payload_json"], "evidence_count": 1}) + created_items[-1] = item + return created_items, created_evidence + + def _audit(self, *, actor_id: str, actor_role: str, signoff_id: str, action_type: str, payload: Dict[str, Any]) -> None: + self.audit.record_audit_log( + actor_id=actor_id, + actor_role=actor_role, + account_id=None, + object_type="production_signoff", + object_id=signoff_id, + action_type=action_type, + source_surface="ops", + customer_visible_payload={}, + internal_payload=payload, + ) + + def initialize_signoff_run( + self, + *, + actor_id: str, + actor_role: str, + launch_label: Optional[str] = None, + due_in_days: int = 2, + ) -> Dict[str, Any]: + checklist = self._latest_go_live_checklist() + manual_sheet = self._latest_manual_signoff_sheet() + resolved_label = str(launch_label or f"production_launch_{self._utcnow().strftime('%Y%m%d')}").strip() + signoff = self.repository.save_production_signoff( + { + "launch_label": resolved_label, + "status": "draft", + "source_go_live_checklist_id": checklist.get("checklist_id"), + "source_manual_signoff_bundle_id": "latest_manual_signoff_sheet", + "rollup_summary": {}, + } + ) + items, evidence = self._initialize_item_records(signoff_id=signoff["signoff_id"], due_in_days=due_in_days) + rollup = self._rollup(items) + signoff = self.repository.save_production_signoff({**signoff, "status": rollup["status"], "rollup_summary_json": rollup}) + result = { + "signoff": signoff, + "items": items, + "evidence": evidence, + "source_checklist_id": checklist.get("checklist_id"), + "source_manual_signoff_count": len(list(manual_sheet.get("items") or [])), + } + self._audit(actor_id=actor_id, actor_role=actor_role, signoff_id=signoff["signoff_id"], action_type="production_signoff_initialized", payload=result) + return result + + def _refresh_signoff_rollup(self, signoff_id: str) -> Dict[str, Any]: + signoff = self.repository.get_production_signoff(signoff_id) + items = self.repository.list_production_signoff_items(signoff_id=signoff_id) + cutover_windows = self.repository.list_production_cutover_windows(signoff_id=signoff_id) + rollup = self._rollup(items, cutover_windows=cutover_windows) + return self.repository.save_production_signoff({**signoff, "status": rollup["status"], "rollup_summary_json": rollup}) + + def list_signoffs(self, *, limit: int = 25) -> Dict[str, Any]: + signoffs = self.repository.list_production_signoffs(limit=limit) + current = signoffs[0] if signoffs else None + return { + "signoffs": signoffs, + "current_signoff": current, + "summary": { + "signoff_count": len(signoffs), + "current_signoff_id": (current or {}).get("signoff_id"), + }, + } + + def signoff_detail(self, *, signoff_id: str) -> Dict[str, Any]: + signoff = self.repository.get_production_signoff(signoff_id) + items = self.repository.list_production_signoff_items(signoff_id=signoff_id) + evidence = self.repository.list_production_signoff_evidence(signoff_id=signoff_id, limit=500) + cutover_windows = self.repository.list_production_cutover_windows(signoff_id=signoff_id, limit=100) + rollup = self._rollup(items, cutover_windows=cutover_windows) + signoff = self.repository.save_production_signoff({**signoff, "status": rollup["status"], "rollup_summary_json": rollup}) + latest_evidence_by_item: Dict[str, Dict[str, Any]] = {} + for item in evidence: + key = str(item.get("signoff_item_id") or "") + if key and key not in latest_evidence_by_item: + latest_evidence_by_item[key] = item + item_details = [] + for item in items: + item_details.append( + { + **item, + "latest_evidence": latest_evidence_by_item.get(str(item.get("signoff_item_id") or "")), + } + ) + return { + "signoff": signoff, + "rollup_summary": rollup, + "items": item_details, + "evidence": evidence, + "cutover_windows": cutover_windows, + "export_refs": { + "record_dir": str(self._artifacts_root() / "production_signoff_records" / signoff_id), + }, + } + + def assign_signoff_item_owner( + self, + *, + actor_id: str, + actor_role: str, + signoff_item_id: str, + owner_actor_id: Optional[str], + ) -> Dict[str, Any]: + item = self.repository.get_production_signoff_item(signoff_item_id) + updated = self.repository.save_production_signoff_item( + { + **item, + "item_payload_json": item.get("item_payload_json", {}), + "owner_actor_id": str(owner_actor_id or "").strip() or None, + } + ) + signoff = self._refresh_signoff_rollup(updated["signoff_id"]) + self._audit( + actor_id=actor_id, + actor_role=actor_role, + signoff_id=updated["signoff_id"], + action_type="production_signoff_item_owner_assigned", + payload={"signoff_item_id": signoff_item_id, "owner_actor_id": updated.get("owner_actor_id")}, + ) + return {"item": updated, "signoff": signoff} + + def append_signoff_evidence( + self, + *, + actor_id: str, + actor_role: str, + signoff_item_id: str, + evidence_type: str, + summary: Optional[str], + source_ref: Optional[Dict[str, Any]] = None, + payload: Optional[Dict[str, Any]] = None, + customer_safe: bool = False, + ) -> Dict[str, Any]: + item = self.repository.get_production_signoff_item(signoff_item_id) + evidence = self.repository.save_production_signoff_evidence( + { + "signoff_id": item["signoff_id"], + "signoff_item_id": signoff_item_id, + "evidence_type": evidence_type, + "summary": summary, + "source_ref": dict(source_ref or {}), + "payload": dict(payload or {}), + "customer_safe": customer_safe, + } + ) + updated_item = self.repository.save_production_signoff_item( + { + **item, + "item_payload_json": item.get("item_payload_json", {}), + "evidence_count": int(item.get("evidence_count") or 0) + 1, + } + ) + signoff = self._refresh_signoff_rollup(updated_item["signoff_id"]) + self._audit( + actor_id=actor_id, + actor_role=actor_role, + signoff_id=updated_item["signoff_id"], + action_type="production_signoff_evidence_appended", + payload={"signoff_item_id": signoff_item_id, "evidence_id": evidence["evidence_id"], "evidence_type": evidence_type}, + ) + return {"item": updated_item, "evidence": evidence, "signoff": signoff} + + def decide_signoff_item( + self, + *, + actor_id: str, + actor_role: str, + signoff_item_id: str, + decision: str, + note: Optional[str] = None, + ) -> Dict[str, Any]: + item = self.repository.get_production_signoff_item(signoff_item_id) + normalized = str(decision or "").strip() + if normalized not in {"approved", "rejected", "waived", "ready_for_review"}: + raise ValueError("production_signoff_decision_invalid:%s" % normalized) + updated = self.repository.save_production_signoff_item( + { + **item, + "item_payload_json": item.get("item_payload_json", {}), + "status": normalized, + "decision_note": note, + "approved_at": self._utcnow_iso() if normalized in APPROVED_ITEM_STATUSES else None, + } + ) + signoff = self._refresh_signoff_rollup(updated["signoff_id"]) + self._audit( + actor_id=actor_id, + actor_role=actor_role, + signoff_id=updated["signoff_id"], + action_type="production_signoff_item_decided", + payload={"signoff_item_id": signoff_item_id, "decision": normalized, "note": note}, + ) + return {"item": updated, "signoff": signoff} + + def mark_cutover_window( + self, + *, + actor_id: str, + actor_role: str, + signoff_id: str, + launch_wave: str, + target_environment: str, + starts_at: Optional[str], + ends_at: Optional[str], + rollback_owner_role: Optional[str], + status: str = "planned", + payload: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + window = self.repository.save_production_cutover_window( + { + "signoff_id": signoff_id, + "launch_wave": launch_wave, + "target_environment": target_environment, + "starts_at": starts_at, + "ends_at": ends_at, + "rollback_owner_role": rollback_owner_role, + "status": status, + "cutover_payload": dict(payload or {}), + } + ) + signoff = self._refresh_signoff_rollup(signoff_id) + self._audit( + actor_id=actor_id, + actor_role=actor_role, + signoff_id=signoff_id, + action_type="production_cutover_window_marked", + payload={"cutover_window_id": window["cutover_window_id"], "status": status}, + ) + return {"cutover_window": window, "signoff": signoff} + + def export_signoff_record(self, *, signoff_id: str) -> Dict[str, Any]: + detail = self.signoff_detail(signoff_id=signoff_id) + output_dir = self._artifacts_root() / "production_signoff_records" / signoff_id + output_dir.mkdir(parents=True, exist_ok=True) + record_json_path = output_dir / "production_signoff_record.json" + record_md_path = output_dir / "production_signoff_record.md" + items_csv_path = output_dir / "production_signoff_items.csv" + record_json_path.write_text(json.dumps(detail, ensure_ascii=False, indent=2), encoding="utf-8") + md_lines = [ + "# Production Signoff Record", + "", + f"- signoff_id: {detail['signoff']['signoff_id']}", + f"- launch_label: {detail['signoff']['launch_label']}", + f"- status: {detail['signoff']['status']}", + f"- pending: {detail['rollup_summary']['pending_item_count']}", + f"- approved: {detail['rollup_summary']['approved_item_count']}", + f"- rejected: {detail['rollup_summary']['rejected_item_count']}", + "", + "## Items", + ] + for item in detail["items"]: + md_lines.extend( + [ + f"### {item['item_code']} — {item['label']}", + f"- status: {item['status']}", + f"- owner_role: {item['owner_role']}", + f"- owner_actor_id: {item.get('owner_actor_id') or '-'}", + f"- due_at: {item.get('due_at') or '-'}", + f"- evidence_count: {item.get('evidence_count') or 0}", + f"- decision_note: {item.get('decision_note') or '-'}", + f"- latest_evidence: {(item.get('latest_evidence') or {}).get('summary') or '-'}", + "", + ] + ) + record_md_path.write_text("\n".join(md_lines), encoding="utf-8") + with items_csv_path.open("w", encoding="utf-8", newline="") as fh: + writer = csv.DictWriter( + fh, + fieldnames=["signoff_item_id", "item_code", "category", "label", "owner_role", "owner_actor_id", "due_at", "status", "decision_note", "approved_at", "evidence_count"], + ) + writer.writeheader() + for item in detail["items"]: + writer.writerow({key: item.get(key) for key in writer.fieldnames}) + return { + "signoff": detail["signoff"], + "export_refs": { + "record_json": str(record_json_path), + "record_md": str(record_md_path), + "items_csv": str(items_csv_path), + }, + } + + def current_signoff_summary(self) -> Optional[Dict[str, Any]]: + signoffs = self.repository.list_production_signoffs(limit=1) + if not signoffs: + return None + signoff = signoffs[0] + items = self.repository.list_production_signoff_items(signoff_id=signoff["signoff_id"]) + cutover_windows = self.repository.list_production_cutover_windows(signoff_id=signoff["signoff_id"], limit=25) + rollup = self._rollup(items, cutover_windows=cutover_windows) + return { + "signoff_id": signoff["signoff_id"], + "launch_label": signoff["launch_label"], + "status": rollup["status"], + "pending_item_count": rollup["pending_item_count"], + "approved_item_count": rollup["approved_item_count"], + "rejected_item_count": rollup["rejected_item_count"], + "next_due_item": rollup["next_due_item"], + "planned_cutover_window": rollup["planned_cutover_window"], + } diff --git a/src/narrativeos/services/production_signoff_board.py b/src/narrativeos/services/production_signoff_board.py new file mode 100644 index 0000000..3e7de5c --- /dev/null +++ b/src/narrativeos/services/production_signoff_board.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from collections import Counter, defaultdict +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from .production_signoff import APPROVED_ITEM_STATUSES, PENDING_ITEM_STATUSES, REJECTED_ITEM_STATUSES, ProductionSignoffService + + +class ProductionSignoffBoardService: + def __init__(self, *, production_signoff_service: ProductionSignoffService) -> None: + self.production_signoff = production_signoff_service + + def _utcnow(self) -> datetime: + return datetime.now(timezone.utc) + + def _parse_dt(self, value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + def _is_seed_only(self, item: Dict[str, Any], evidence_rows: List[Dict[str, Any]]) -> bool: + relevant = [row for row in evidence_rows if str(row.get("signoff_item_id") or "") == str(item.get("signoff_item_id") or "")] + if not relevant: + return True + return all(str(row.get("summary") or "").startswith("seeded:") for row in relevant) + + def _blockers_for_item(self, item: Dict[str, Any], evidence_rows: List[Dict[str, Any]]) -> List[str]: + blockers: List[str] = [] + requires_manual = bool((item.get("item_payload_json") or {}).get("requires_manual_confirmation")) + status = str(item.get("status") or "") + due_at = self._parse_dt(item.get("due_at")) + is_actionable = requires_manual or status in PENDING_ITEM_STATUSES or status in REJECTED_ITEM_STATUSES + if is_actionable and not str(item.get("owner_actor_id") or "").strip(): + blockers.append("owner_missing") + if is_actionable and not item.get("due_at"): + blockers.append("due_missing") + if status in PENDING_ITEM_STATUSES and due_at and due_at < self._utcnow(): + blockers.append("overdue") + if requires_manual and status not in APPROVED_ITEM_STATUSES: + blockers.append("manual_confirmation_pending") + if is_actionable and self._is_seed_only(item, evidence_rows): + blockers.append("no_actionable_evidence_beyond_seed") + if status in REJECTED_ITEM_STATUSES: + blockers.append("rejected") + return blockers + + def board(self, *, signoff_id: Optional[str] = None) -> Dict[str, Any]: + if signoff_id: + detail = self.production_signoff.signoff_detail(signoff_id=signoff_id) + else: + current = self.production_signoff.current_signoff_summary() + if not current: + return { + "board_status": "not_initialized", + "current_signoff": None, + "items": [], + "summary": { + "item_count": 0, + "pending_item_count": 0, + "approved_item_count": 0, + "rejected_item_count": 0, + "overdue_count": 0, + "blocker_counts": {}, + "owner_status_buckets": {}, + }, + "overdue_items": [], + "export_refs": {}, + } + detail = self.production_signoff.signoff_detail(signoff_id=current["signoff_id"]) + evidence_rows = list(detail.get("evidence") or []) + items_with_blockers: List[Dict[str, Any]] = [] + blocker_counts: Counter[str] = Counter() + owner_status_buckets: Dict[str, Dict[str, int]] = defaultdict(lambda: Counter()) + overdue_items: List[Dict[str, Any]] = [] + for item in list(detail.get("items") or []): + blockers = self._blockers_for_item(item, evidence_rows) + blocker_counts.update(blockers) + bucket_key = str(item.get("owner_role") or "unassigned") + owner_status_buckets[bucket_key][str(item.get("status") or "unknown")] += 1 + materialized = { + **item, + "blockers": blockers, + "has_blockers": bool(blockers), + "latest_evidence_ref": ((item.get("latest_evidence") or {}).get("source_ref_json") or {}), + } + items_with_blockers.append(materialized) + if "overdue" in blockers: + overdue_items.append(materialized) + return { + "board_status": "active", + "current_signoff": detail.get("signoff"), + "items": items_with_blockers, + "summary": { + "item_count": len(items_with_blockers), + "pending_item_count": sum(1 for item in items_with_blockers if str(item.get("status") or "") in PENDING_ITEM_STATUSES), + "approved_item_count": sum(1 for item in items_with_blockers if str(item.get("status") or "") in APPROVED_ITEM_STATUSES), + "rejected_item_count": sum(1 for item in items_with_blockers if str(item.get("status") or "") in REJECTED_ITEM_STATUSES), + "overdue_count": len(overdue_items), + "blocker_counts": dict(blocker_counts), + "owner_status_buckets": {key: dict(value) for key, value in owner_status_buckets.items()}, + "linked_cutover_window": detail.get("rollup_summary", {}).get("planned_cutover_window"), + "next_due_item": detail.get("rollup_summary", {}).get("next_due_item"), + }, + "overdue_items": overdue_items, + "export_refs": detail.get("export_refs") or {}, + } + + def current_board_summary(self) -> Optional[Dict[str, Any]]: + board = self.board() + if board["board_status"] == "not_initialized": + return None + signoff = board.get("current_signoff") or {} + summary = board.get("summary") or {} + return { + "signoff_id": signoff.get("signoff_id"), + "status": signoff.get("status"), + "pending_item_count": summary.get("pending_item_count", 0), + "approved_item_count": summary.get("approved_item_count", 0), + "rejected_item_count": summary.get("rejected_item_count", 0), + "overdue_count": summary.get("overdue_count", 0), + "blocker_counts": summary.get("blocker_counts") or {}, + "next_due_item": summary.get("next_due_item"), + "linked_cutover_window": summary.get("linked_cutover_window"), + "export_refs": board.get("export_refs") or {}, + } diff --git a/src/narrativeos/services/quantum_read_models.py b/src/narrativeos/services/quantum_read_models.py new file mode 100644 index 0000000..aebc75e --- /dev/null +++ b/src/narrativeos/services/quantum_read_models.py @@ -0,0 +1,1207 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, TYPE_CHECKING +from urllib.parse import quote + +from ..persistence.repositories import SQLAlchemyPlatformRepository + +if TYPE_CHECKING: + from .analytics import AnalyticsService + from .billing import BillingService + from .author_work import AuthorWorkService + from .illustration import IllustrationService + from .library_stats_cube import LibraryStatsCubeService + + +SOUL_DIMENSION_LABELS = ("理性", "情感", "冒险", "命运", "混沌") +VALID_SOUL_PRIVACY_MODES = {"public", "followers", "private"} +VALID_LIBRARY_FILTERS = {"recent", "favorites", "following", "completed"} +VALID_SHOWCASE_SORTS = {"hot", "new", "ongoing"} + + +def _is_public_catalog_visible(metadata: Dict[str, Any]) -> bool: + if str(metadata.get("catalog_role") or "").strip() == "template": + return False + if metadata.get("public_catalog_visible") is False: + return False + return True + + +def _parse_timestamp(value: Optional[str]) -> datetime: + normalized = str(value or "").strip() + if not normalized: + return datetime.fromtimestamp(0, tz=timezone.utc) + try: + parsed = datetime.fromisoformat(normalized.replace("Z", "+00:00")) + except ValueError: + return datetime.fromtimestamp(0, tz=timezone.utc) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _clamp_percent(value: Any) -> int: + try: + numeric = int(round(float(value or 0))) + except (TypeError, ValueError): + numeric = 0 + return max(0, min(100, numeric)) + + +class QuantumReadModelService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + author_work_service: "AuthorWorkService", + analytics_service: Optional["AnalyticsService"] = None, + billing_service: Optional["BillingService"] = None, + library_stats_cube_service: Optional["LibraryStatsCubeService"] = None, + illustration_service: Optional["IllustrationService"] = None, + ) -> None: + self.repository = repository + self.author_work_service = author_work_service + self.analytics = analytics_service + self.billing = billing_service + self.library_stats_cube = library_stats_cube_service + self.illustration = illustration_service + + def _world_cover_url(self, *, world_version_id: str) -> str: + if self.illustration is None or not world_version_id: + return "" + return self.illustration.world_cover_url(world_version_id=world_version_id) + + def _session_cover_url(self, *, session_id: str, world_version_id: str) -> str: + if self.illustration is None: + return "" + return self.illustration.session_cover_url(session_id=session_id) or self._world_cover_url( + world_version_id=world_version_id + ) + + def _resolve_identity_by_user_id(self, user_id: str) -> Optional[Dict[str, Any]]: + normalized = str(user_id or "").strip() + if not normalized or normalized == "guest": + return None + try: + return self.repository.get_auth_identity(normalized) + except KeyError: + pass + try: + return self.repository.get_auth_identity_by_account_id(normalized) + except KeyError: + return None + + def _default_soul_preferences(self, *, actor_id: str, account_id: Optional[str]) -> Dict[str, Any]: + return { + "actor_id": actor_id, + "account_id": account_id, + "genres": [], + "styles": [], + "privacy_mode": "followers", + } + + def get_soul_preferences(self, *, actor_id: str, account_id: Optional[str]) -> Dict[str, Any]: + return self.repository.get_soul_profile_preferences( + actor_id, + default=self._default_soul_preferences(actor_id=actor_id, account_id=account_id), + ) + + def update_soul_preferences( + self, + *, + actor_id: str, + account_id: Optional[str], + genres: Optional[List[str]] = None, + styles: Optional[List[str]] = None, + privacy_mode: Optional[str] = None, + ) -> Dict[str, Any]: + existing = self.get_soul_preferences(actor_id=actor_id, account_id=account_id) + next_privacy_mode = str(privacy_mode or existing.get("privacy_mode") or "followers").strip() or "followers" + if next_privacy_mode not in VALID_SOUL_PRIVACY_MODES: + raise ValueError("soul_preferences_privacy_invalid") + return self.repository.save_soul_profile_preferences( + { + "actor_id": actor_id, + "account_id": account_id, + "genres": existing.get("genres") if genres is None else genres, + "styles": existing.get("styles") if styles is None else styles, + "privacy_mode": next_privacy_mode, + } + ) + + def _reader_continue_counts_by_session(self, *, account_id: str) -> Dict[str, int]: + counts: Dict[str, int] = {} + for event in self.repository.list_analytics_events( + event_names=["continue_story"], + reader_id=account_id, + limit=1000, + ): + session_id = str(event.get("session_id") or "").strip() + if not session_id: + continue + counts[session_id] = counts.get(session_id, 0) + 1 + return counts + + def _reader_recent_activity_map(self, *, account_id: str) -> Dict[str, Dict[str, Any]]: + items: Dict[str, Dict[str, Any]] = {} + for event in self.repository.list_analytics_events( + event_names=["session_created", "continue_story", "story_share_created", "story_share_revoked"], + reader_id=account_id, + limit=1000, + ): + session_id = str(event.get("session_id") or "").strip() + if not session_id: + continue + occurred_at = str(event.get("occurred_at") or "") + current = items.get(session_id) + if current is None or _parse_timestamp(occurred_at) > _parse_timestamp(current.get("occurred_at")): + items[session_id] = dict(event) + return items + + def _bookmark_summary_by_session(self, *, account_id: str) -> Dict[str, Dict[str, Any]]: + summary: Dict[str, Dict[str, Any]] = {} + for item in self.repository.list_story_session_bookmarks(account_id=account_id): + session_id = str(item.get("session_id") or "").strip() + node_id = str(item.get("node_id") or "").strip() + if not session_id: + continue + entry = summary.setdefault( + session_id, + {"node_ids": set(), "count": 0, "latest_node_id": None, "_updated_at_dt": datetime.fromtimestamp(0, tz=timezone.utc)}, + ) + if node_id: + entry["node_ids"].add(node_id) + entry["count"] += 1 + updated_at = _parse_timestamp(item.get("updated_at") or item.get("created_at")) + if updated_at >= entry["_updated_at_dt"]: + entry["_updated_at_dt"] = updated_at + entry["latest_node_id"] = node_id or entry.get("latest_node_id") + return summary + + def _story_session_current_node_id(self, session_id: str) -> str: + latest_step = self.repository.get_latest_step(session_id) + if latest_step is not None: + step_index = int(getattr(latest_step, "step_index", 0) or 0) + if step_index > 0: + return f"node:{session_id}:{step_index}" + return f"node:{session_id}:0" + + def _library_favorite_state(self, *, account_id: str) -> Dict[str, Any]: + state_by_work_id: Dict[str, Dict[str, Any]] = {} + for event in self.repository.list_analytics_events( + event_names=["library_work_favorited", "library_work_unfavorited"], + reader_id=account_id, + limit=4000, + ): + payload = dict(event.get("payload_json") or {}) + work_id = str(payload.get("work_id") or "").strip() + work_kind = str(payload.get("work_kind") or "").strip() or None + if not work_id: + continue + occurred_at = _parse_timestamp(event.get("occurred_at")) + current = state_by_work_id.get(work_id) + if current is None or occurred_at >= current["_occurred_at_dt"]: + state_by_work_id[work_id] = { + "active": str(event.get("event_name") or "") == "library_work_favorited", + "work_kind": work_kind, + "_occurred_at_dt": occurred_at, + } + reader_session_ids = { + work_id + for work_id, item in state_by_work_id.items() + if item.get("active") and item.get("work_kind") == "reader_session" + } + author_work_ids = { + work_id + for work_id, item in state_by_work_id.items() + if item.get("active") and item.get("work_kind") == "author_work" + } + active_work_ids = { + work_id + for work_id, item in state_by_work_id.items() + if item.get("active") + } + updated_at_by_work_id = { + work_id: str(item["_occurred_at_dt"].isoformat()) + for work_id, item in state_by_work_id.items() + if item.get("active") + } + return { + "reader_session_ids": reader_session_ids, + "author_work_ids": author_work_ids, + "active_work_ids": active_work_ids, + "updated_at_by_work_id": updated_at_by_work_id, + } + + def _library_follow_state(self, *, account_id: str) -> Dict[str, Any]: + state_by_target: Dict[tuple[str, str], Dict[str, Any]] = {} + for event in self.repository.list_analytics_events( + event_names=["library_target_followed", "library_target_unfollowed"], + reader_id=account_id, + limit=4000, + ): + payload = dict(event.get("payload_json") or {}) + target_type = str(payload.get("target_type") or "").strip() + target_id = str(payload.get("target_id") or "").strip() + if not target_type or not target_id: + continue + occurred_at = _parse_timestamp(event.get("occurred_at")) + key = (target_type, target_id) + current = state_by_target.get(key) + if current is None or occurred_at >= current["_occurred_at_dt"]: + state_by_target[key] = { + "active": str(event.get("event_name") or "") == "library_target_followed", + "_occurred_at_dt": occurred_at, + } + author_ids = { + target_id + for (target_type, target_id), item in state_by_target.items() + if target_type == "author" and item.get("active") + } + world_ids = { + target_id + for (target_type, target_id), item in state_by_target.items() + if target_type == "world" and item.get("active") + } + updated_at_by_target = { + f"{target_type}:{target_id}": str(item["_occurred_at_dt"].isoformat()) + for (target_type, target_id), item in state_by_target.items() + if item.get("active") + } + return {"author_ids": author_ids, "world_ids": world_ids, "updated_at_by_target": updated_at_by_target} + + def _library_bookmark_state(self, *, account_id: str) -> Dict[str, Dict[str, Any]]: + state_by_key: Dict[tuple[str, str], Dict[str, Any]] = {} + for event in self.repository.list_analytics_events( + event_names=["story_bookmark_created", "story_bookmark_deleted"], + reader_id=account_id, + limit=4000, + ): + payload = dict(event.get("payload_json") or {}) + session_id = str(event.get("session_id") or payload.get("session_id") or "").strip() + node_id = str(payload.get("node_id") or "").strip() + if not session_id or not node_id: + continue + occurred_at = _parse_timestamp(event.get("occurred_at")) + key = (session_id, node_id) + current = state_by_key.get(key) + if current is None or occurred_at >= current["_occurred_at_dt"]: + state_by_key[key] = { + "active": str(event.get("event_name") or "") == "story_bookmark_created", + "_occurred_at_dt": occurred_at, + } + summary: Dict[str, Dict[str, Any]] = {} + for (session_id, node_id), item in state_by_key.items(): + if not item.get("active"): + continue + entry = summary.setdefault( + session_id, + {"node_ids": set(), "count": 0, "latest_node_id": None, "_updated_at_dt": datetime.fromtimestamp(0, tz=timezone.utc)}, + ) + entry["node_ids"].add(node_id) + entry["count"] += 1 + updated_at = item["_occurred_at_dt"] + if updated_at >= entry["_updated_at_dt"]: + entry["_updated_at_dt"] = updated_at + entry["latest_node_id"] = node_id + return summary + + def _canonical_library_state_bundle(self, *, account_id: str) -> Dict[str, Any]: + favorite_state = self._library_favorite_state(account_id=account_id) + follow_state = self._library_follow_state(account_id=account_id) + bookmark_state = self._library_bookmark_state(account_id=account_id) + return { + "favorite_state": favorite_state, + "follow_state": follow_state, + "bookmark_state": bookmark_state, + } + + def _projected_current_node_id(self, session_id: str) -> str: + try: + session = self.repository.get_session(session_id) + except KeyError: + return f"node:{session_id}:0" + return f"node:{session_id}:{int(getattr(session.current_state, 'chapter_index', 0) or 0)}" + + def _reader_session_items( + self, + *, + account_id: str, + favorite_ids_override: Optional[set[str]] = None, + followed_world_ids_override: Optional[set[str]] = None, + bookmark_summary_override: Optional[Dict[str, Dict[str, Any]]] = None, + current_node_resolver: Optional[Any] = None, + ) -> List[Dict[str, Any]]: + activity_by_session = self._reader_recent_activity_map(account_id=account_id) + continue_counts = self._reader_continue_counts_by_session(account_id=account_id) + bookmark_summary = ( + bookmark_summary_override + if bookmark_summary_override is not None + else self._bookmark_summary_by_session(account_id=account_id) + ) + followed_world_ids = ( + followed_world_ids_override + if followed_world_ids_override is not None + else self._follow_sets(account_id=account_id)["world_ids"] + ) + favorite_ids = ( + favorite_ids_override + if favorite_ids_override is not None + else { + str(item.get("work_id") or "") + for item in self.repository.list_library_work_favorites(account_id=account_id) + if str(item.get("work_kind") or "") == "reader_session" + } + ) + items: List[Dict[str, Any]] = [] + for session in self.repository.list_sessions(): + session_id = str(session.get("session_id") or "").strip() + if not session_id: + continue + try: + detail = self.repository.get_session(session_id) + except KeyError: + continue + owner_account_id = str(detail.metadata.get("reader_id") or detail.player_profile.get("reader_id") or "").strip() + if owner_account_id != account_id: + continue + activity = activity_by_session.get(session_id, {}) + updated_at = str(activity.get("occurred_at") or session.get("created_at") or "") + current_node_id = ( + current_node_resolver(session_id) + if current_node_resolver is not None + else self._projected_current_node_id(session_id) + ) + bookmark_entry = dict(bookmark_summary.get(session_id) or {}) + progress_units = int(session.get("current_turn_index") or 0) + deviation_value = _clamp_percent(float(getattr(detail.current_state, "fate_pressure", 0.0) or 0.0) * 100.0) + items.append( + { + "id": session_id, + "title": str(session.get("last_chapter_title") or session.get("world_id") or session_id), + "coverImage": self._session_cover_url( + session_id=session_id, + world_version_id=str(session.get("world_version_id") or ""), + ), + "author": "最近阅读", + "status": str(detail.status or "active") if getattr(detail, "status", None) else "active", + "progress": min(100, progress_units * 10), + "branchCount": int(continue_counts.get(session_id, 0)), + "endingCount": 1 if str(getattr(detail, "status", "") or "") == "completed" else 0, + "totalEndings": 1, + "deviation": deviation_value, + "lastPlayedAt": updated_at, + "universe": str(session.get("world_id") or ""), + "worldId": str(session.get("world_id") or ""), + "kind": "reader_session", + "targetHref": f"/story?session={quote(session_id, safe='')}", + "updatedAt": updated_at, + "currentNodeId": current_node_id, + "viewerHasBookmarkedCurrentNode": current_node_id in set(bookmark_entry.get("node_ids") or set()), + "bookmarkCount": int(bookmark_entry.get("count") or 0), + "isFavorited": session_id in favorite_ids, + "viewerHasFollowedWorld": str(session.get("world_id") or "") in followed_world_ids, + "_updated_at_dt": _parse_timestamp(updated_at), + "_world_id": str(session.get("world_id") or ""), + "_account_id": account_id, + "_work_kind": "reader_session", + } + ) + items.sort(key=lambda item: item["_updated_at_dt"], reverse=True) + return items + + def _author_work_items( + self, + *, + account_id: str, + favorite_ids_override: Optional[set[str]] = None, + followed_world_ids_override: Optional[set[str]] = None, + ) -> List[Dict[str, Any]]: + works = list((self.author_work_service.list_works(account_id=account_id) or {}).get("works") or []) + followed_world_ids = ( + followed_world_ids_override + if followed_world_ids_override is not None + else self._follow_sets(account_id=account_id)["world_ids"] + ) + favorite_ids = ( + favorite_ids_override + if favorite_ids_override is not None + else { + str(item.get("work_id") or "") + for item in self.repository.list_library_work_favorites(account_id=account_id) + if str(item.get("work_kind") or "") == "author_work" + } + ) + items: List[Dict[str, Any]] = [] + for work in works: + branch_family = list(work.get("branch_family") or []) + target_count = int(work.get("target_chapter_count") or 0) + chapter_count = int(work.get("chapter_count") or 0) + world_version_id = str(work.get("world_version_id") or "").strip() + world_id = world_version_id.split("@")[0] if world_version_id else "" + updated_at = str(work.get("updated_at") or "") + diagnostics = dict(work.get("diagnostics_summary_json") or {}) + top_issue_codes = [str(item) for item in list(diagnostics.get("top_issue_codes") or []) if str(item).strip()] + items.append( + { + "id": str(work.get("work_id") or ""), + "title": str(work.get("title") or world_version_id or "未命名作品"), + "coverImage": self._world_cover_url(world_version_id=world_version_id), + "author": "我的创作", + "status": "completed" if str(work.get("status") or "") == "submitted" else "active", + "progress": round((chapter_count / target_count) * 100) if target_count > 0 else min(100, chapter_count * 10), + "branchCount": sum(1 for item in branch_family if str(item.get("branch_kind") or "") == "parallel_universe"), + "endingCount": 1 if str(work.get("status") or "") == "submitted" else 0, + "totalEndings": max(1, sum(1 for item in branch_family if str(item.get("branch_kind") or "") == "parallel_universe") + 1), + "deviation": _clamp_percent(len(top_issue_codes) * 12), + "lastPlayedAt": updated_at, + "universe": world_id, + "worldId": world_id, + "kind": "author_work", + "targetHref": f"/studio/{quote(world_version_id, safe='')}" if world_version_id else "/studio", + "updatedAt": updated_at, + "isFavorited": str(work.get("work_id") or "") in favorite_ids, + "viewerHasFollowedWorld": world_id in followed_world_ids, + "_updated_at_dt": _parse_timestamp(updated_at), + "_world_id": world_id, + "_account_id": account_id, + "_work_kind": "author_work", + "_author_id": account_id, + } + ) + items.sort(key=lambda item: item["_updated_at_dt"], reverse=True) + return items + + def _author_draft_activity_items(self, *, account_id: str) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + for item in self.repository.list_world_versions(): + if str(item.get("author_id") or "").strip() != account_id: + continue + world_version_id = str(item.get("world_version_id") or "").strip() + if not world_version_id: + continue + updated_at = str(item.get("updated_at") or "") + items.append( + { + "id": world_version_id, + "title": str(item.get("title") or world_version_id), + "coverImage": self._world_cover_url(world_version_id=world_version_id), + "branchName": "创作草稿", + "progress": 0, + "kind": "author_draft", + "targetHref": f"/studio/{quote(world_version_id, safe='')}", + "updatedAt": updated_at, + "_updated_at_dt": _parse_timestamp(updated_at), + } + ) + items.sort(key=lambda item: item["_updated_at_dt"], reverse=True) + return items + + def _follow_sets(self, *, account_id: str) -> Dict[str, set[str]]: + author_ids: set[str] = set() + world_ids: set[str] = set() + for item in self.repository.list_library_follows(account_id=account_id): + target_type = str(item.get("target_type") or "") + target_id = str(item.get("target_id") or "") + if target_type == "author": + author_ids.add(target_id) + elif target_type == "world": + world_ids.add(target_id) + return {"author_ids": author_ids, "world_ids": world_ids} + + def _canonical_world_follow_target(self, target_id: str) -> str: + normalized = str(target_id or "").strip() + if not normalized: + raise KeyError("library_follow_target_missing") + try: + version = self.repository.get_world_version(normalized) + except KeyError: + version = None + if version is not None: + return str(version.world_id or "").strip() + world_ids = { + str(item.get("world_id") or "").strip() + for item in self.repository.list_worlds() + if str(item.get("world_id") or "").strip() + } + if normalized in world_ids: + return normalized + if self.repository.list_world_versions(world_id=normalized): + return normalized + raise KeyError(f"library_follow_target_missing:{normalized}") + + def _canonical_author_follow_target(self, target_id: str) -> str: + identity = self._resolve_identity_by_user_id(target_id) + if identity is None: + raise KeyError(f"library_follow_target_missing:{str(target_id or '').strip()}") + actor_id = str(identity.get("actor_id") or "").strip() + if not actor_id: + raise KeyError(f"library_follow_target_missing:{str(target_id or '').strip()}") + return actor_id + + def follow_library_target( + self, + *, + account_id: str, + actor_id: Optional[str], + target_type: str, + target_id: str, + ) -> Dict[str, Any]: + normalized_type = str(target_type or "").strip() + if normalized_type == "world": + canonical_target_id = self._canonical_world_follow_target(target_id) + elif normalized_type == "author": + canonical_target_id = self._canonical_author_follow_target(target_id) + if actor_id and canonical_target_id == str(actor_id or "").strip(): + raise ValueError("library_follow_self_forbidden") + else: + raise ValueError("library_follow_target_type_invalid") + payload = self.repository.save_library_follow( + { + "account_id": account_id, + "target_type": normalized_type, + "target_id": canonical_target_id, + } + ) + if self.analytics is not None: + self.analytics.track( + "library_target_followed", + reader_id=account_id, + account_id=account_id, + payload_json={ + "target_type": normalized_type, + "target_id": canonical_target_id, + }, + ) + return { + "followId": payload.get("follow_id"), + "targetType": normalized_type, + "targetId": canonical_target_id, + "following": True, + } + + def unfollow_library_target( + self, + *, + account_id: str, + actor_id: Optional[str], + target_type: str, + target_id: str, + ) -> Dict[str, Any]: + normalized_type = str(target_type or "").strip() + if normalized_type == "world": + canonical_target_id = self._canonical_world_follow_target(target_id) + elif normalized_type == "author": + canonical_target_id = self._canonical_author_follow_target(target_id) + if actor_id and canonical_target_id == str(actor_id or "").strip(): + raise ValueError("library_follow_self_forbidden") + else: + raise ValueError("library_follow_target_type_invalid") + payload = self.repository.delete_library_follow( + account_id=account_id, + target_type=normalized_type, + target_id=canonical_target_id, + ) + if self.analytics is not None: + self.analytics.track( + "library_target_unfollowed", + reader_id=account_id, + account_id=account_id, + payload_json={ + "target_type": normalized_type, + "target_id": canonical_target_id, + }, + ) + return { + "followId": payload.get("follow_id"), + "targetType": normalized_type, + "targetId": canonical_target_id, + "following": False, + "deleted": bool(payload.get("deleted")), + } + + def _all_library_items(self, *, account_id: str) -> List[Dict[str, Any]]: + state_bundle = self._canonical_library_state_bundle(account_id=account_id) + items = self._reader_session_items( + account_id=account_id, + favorite_ids_override=set(state_bundle["favorite_state"]["reader_session_ids"]), + followed_world_ids_override=set(state_bundle["follow_state"]["world_ids"]), + bookmark_summary_override=state_bundle["bookmark_state"], + current_node_resolver=self._story_session_current_node_id, + ) + self._author_work_items( + account_id=account_id, + favorite_ids_override=set(state_bundle["favorite_state"]["author_work_ids"]), + followed_world_ids_override=set(state_bundle["follow_state"]["world_ids"]), + ) + items.sort(key=lambda item: item["_updated_at_dt"], reverse=True) + return items + + def library_works(self, *, account_id: Optional[str], filter_value: str) -> List[Dict[str, Any]]: + if not account_id: + return [] + normalized = str(filter_value or "").strip() + if normalized not in VALID_LIBRARY_FILTERS: + normalized = "recent" + state_bundle = self._canonical_library_state_bundle(account_id=account_id) + items = self._reader_session_items( + account_id=account_id, + favorite_ids_override=set(state_bundle["favorite_state"]["reader_session_ids"]), + followed_world_ids_override=set(state_bundle["follow_state"]["world_ids"]), + bookmark_summary_override=state_bundle["bookmark_state"], + current_node_resolver=self._story_session_current_node_id, + ) + self._author_work_items( + account_id=account_id, + favorite_ids_override=set(state_bundle["favorite_state"]["author_work_ids"]), + followed_world_ids_override=set(state_bundle["follow_state"]["world_ids"]), + ) + items.sort(key=lambda item: item["_updated_at_dt"], reverse=True) + if normalized == "recent": + selected = items + elif normalized == "favorites": + selected = [item for item in items if bool(item.get("isFavorited"))] + elif normalized == "following": + selected = [ + item for item in items + if str(item.get("_world_id") or "") in state_bundle["follow_state"]["world_ids"] + ] + else: + selected = [item for item in items if str(item.get("status") or "") == "completed"] + output: List[Dict[str, Any]] = [] + for item in selected: + payload = dict(item) + for transient in ("_updated_at_dt", "_world_id", "_account_id", "_work_kind", "_author_id"): + payload.pop(transient, None) + output.append(payload) + return output + + def resolve_library_work(self, *, account_id: str, work_id: str) -> Dict[str, Any]: + for item in self._all_library_items(account_id=account_id): + if str(item.get("id") or "") == work_id: + return item + raise KeyError("library_work_missing:%s" % work_id) + + def favorite_library_work(self, *, account_id: str, work_id: str) -> Dict[str, Any]: + work = self.resolve_library_work(account_id=account_id, work_id=work_id) + payload = self.repository.save_library_work_favorite( + { + "account_id": account_id, + "work_id": work_id, + "work_kind": work.get("_work_kind") or work.get("kind") or "reader_session", + "title_snapshot": work.get("title"), + } + ) + if self.analytics is not None: + self.analytics.track( + "library_work_favorited", + reader_id=account_id, + account_id=account_id, + payload_json={ + "work_id": work_id, + "work_kind": payload.get("work_kind") or work.get("_work_kind") or work.get("kind") or "reader_session", + }, + ) + return payload + + def unfavorite_library_work(self, *, account_id: str, work_id: str) -> Dict[str, Any]: + payload = self.repository.delete_library_work_favorite(account_id=account_id, work_id=work_id) + if self.analytics is not None: + self.analytics.track( + "library_work_unfavorited", + reader_id=account_id, + account_id=account_id, + payload_json={ + "work_id": work_id, + "work_kind": payload.get("work_kind"), + }, + ) + return payload + + def bookmark_story_node(self, *, account_id: str, session_id: str, node_id: str) -> Dict[str, Any]: + payload = self.repository.save_story_session_bookmark( + { + "session_id": session_id, + "account_id": account_id, + "node_id": node_id, + } + ) + if self.analytics is not None: + try: + session = self.repository.get_session(session_id) + world_id = session.world_id + world_version_id = session.metadata.get("world_version_id") + except KeyError: + world_id = None + world_version_id = None + self.analytics.track( + "story_bookmark_created", + reader_id=account_id, + account_id=account_id, + session_id=session_id, + world_id=world_id, + world_version_id=world_version_id, + payload_json={"node_id": node_id}, + ) + return payload + + def unbookmark_story_node(self, *, account_id: str, session_id: str, node_id: str) -> Dict[str, Any]: + payload = self.repository.delete_story_session_bookmark( + session_id=session_id, + account_id=account_id, + node_id=node_id, + ) + if self.analytics is not None: + try: + session = self.repository.get_session(session_id) + world_id = session.world_id + world_version_id = session.metadata.get("world_version_id") + except KeyError: + world_id = None + world_version_id = None + self.analytics.track( + "story_bookmark_deleted", + reader_id=account_id, + account_id=account_id, + session_id=session_id, + world_id=world_id, + world_version_id=world_version_id, + payload_json={"node_id": node_id}, + ) + return payload + + def library_stats(self, *, account_id: Optional[str]) -> Dict[str, Any]: + if not account_id or self.library_stats_cube is None: + return { + "totalPlayTime": 0, + "totalBranches": 0, + "worldFragments": 0, + "totalFragments": 0, + } + return self.library_stats_cube.get_stats(account_id=account_id) + + def library_achievements(self, *, account_id: Optional[str]) -> List[Dict[str, Any]]: + if not account_id: + return [] + state_bundle = self._canonical_library_state_bundle(account_id=account_id) + sessions = self._reader_session_items( + account_id=account_id, + favorite_ids_override=set(state_bundle["favorite_state"]["reader_session_ids"]), + followed_world_ids_override=set(state_bundle["follow_state"]["world_ids"]), + bookmark_summary_override=state_bundle["bookmark_state"], + current_node_resolver=self._story_session_current_node_id, + ) + author_items = self._author_work_items( + account_id=account_id, + favorite_ids_override=set(state_bundle["favorite_state"]["author_work_ids"]), + followed_world_ids_override=set(state_bundle["follow_state"]["world_ids"]), + ) + continue_counts = self._reader_continue_counts_by_session(account_id=account_id) + unlocked_world_count = len({str(item.get("_world_id") or "") for item in sessions + author_items if str(item.get("_world_id") or "").strip()}) + favorite_updated_ats = list(state_bundle["favorite_state"]["updated_at_by_work_id"].values()) + follow_updated_ats = list(state_bundle["follow_state"]["updated_at_by_target"].values()) + return [ + { + "id": "first_branch", + "title": "第一次偏离", + "icon": "🜂", + "color": "amber", + "unlocked": bool(sum(continue_counts.values()) >= 1), + "unlockedAt": sessions[0].get("updatedAt") if sessions else None, + }, + { + "id": "world_cartographer", + "title": "世界测绘者", + "icon": "🜁", + "color": "cyan", + "unlocked": unlocked_world_count >= 2, + "unlockedAt": (sessions[0].get("updatedAt") if sessions else None) or (author_items[0].get("updatedAt") if author_items else None), + }, + { + "id": "archive_curator", + "title": "书馆策展人", + "icon": "✦", + "color": "violet", + "unlocked": len(state_bundle["favorite_state"]["active_work_ids"]) >= 1, + "unlockedAt": max(favorite_updated_ats) if favorite_updated_ats else None, + }, + { + "id": "co_creator", + "title": "共创者", + "icon": "✎", + "color": "emerald", + "unlocked": bool(state_bundle["follow_state"]["updated_at_by_target"]), + "unlockedAt": max(follow_updated_ats) if follow_updated_ats else None, + }, + ] + + def _recent_activity_count(self, *, account_id: str) -> int: + window_start = datetime.now(timezone.utc).timestamp() - (24 * 60 * 60) + events = self.repository.list_analytics_events( + event_names=[ + "session_created", + "continue_story", + "author_draft_created_from_brief", + "author_draft_saved", + "author_draft_updated", + "author_draft_validated", + "author_draft_simulated", + "author_draft_submitted", + "author_longform_workbench_bootstrapped", + "library_work_favorited", + "library_work_unfavorited", + "library_target_followed", + "library_target_unfollowed", + "story_bookmark_created", + "story_bookmark_deleted", + "showcase_work_liked", + "showcase_work_commented", + "showcase_tip_sent", + ], + reader_id=account_id, + limit=1000, + ) + count = 0 + for event in events: + if _parse_timestamp(event.get("occurred_at")).timestamp() >= window_start: + count += 1 + return count + + def _soul_dimensions(self, *, account_id: str) -> List[Dict[str, Any]]: + state_bundle = self._canonical_library_state_bundle(account_id=account_id) + favorite_state = state_bundle["favorite_state"] + follow_state = state_bundle["follow_state"] + bookmark_state = state_bundle["bookmark_state"] + sessions = self._reader_session_items( + account_id=account_id, + favorite_ids_override=set(favorite_state["reader_session_ids"]), + followed_world_ids_override=set(follow_state["world_ids"]), + bookmark_summary_override=bookmark_state, + current_node_resolver=self._story_session_current_node_id, + ) + author_items = self._author_work_items( + account_id=account_id, + favorite_ids_override=set(favorite_state["author_work_ids"]), + followed_world_ids_override=set(follow_state["world_ids"]), + ) + likes = len(self.repository.list_showcase_work_likes(account_id=account_id)) + comments = len([item for item in self.repository.list_showcase_work_comments(limit=200) if str(item.get("account_id") or "") == account_id]) + tips = self.repository.list_showcase_work_tips(account_id=account_id) + continue_total = sum(self._reader_continue_counts_by_session(account_id=account_id).values()) + unique_worlds = len({str(item.get("_world_id") or "") for item in sessions + author_items if str(item.get("_world_id") or "").strip()}) + tip_total = sum(int(item.get("amount") or 0) for item in tips) + favorites = len(favorite_state["active_work_ids"]) + dims = [ + _clamp_percent((continue_total * 14) + (len(author_items) * 10)), + _clamp_percent((comments * 18) + (likes * 8) + (len(tips) * 12)), + _clamp_percent((continue_total * 10) + (unique_worlds * 12)), + _clamp_percent((tip_total / 2) + (favorites * 8) + (20 if self.billing and str((self.billing.subscription_status(account_id=account_id) or {}).get("effective_tier") or "free") != "free" else 0)), + _clamp_percent((sum(int(item.get("deviation") or 0) for item in sessions + author_items) / max(1, len(sessions) + len(author_items))) + (favorites * 6)), + ] + return [ + {"label": label, "value": value, "max": 100} + for label, value in zip(SOUL_DIMENSION_LABELS, dims) + ] + + def _public_activity_feed(self, *, account_id: str, limit: int = 3) -> List[Dict[str, Any]]: + items = self._all_library_items(account_id=account_id) + public_items: List[Dict[str, Any]] = [] + for item in items[:limit]: + public_items.append( + { + "id": item.get("id"), + "title": item.get("title"), + "coverImage": item.get("coverImage") or "", + "branchName": "公开活动" if item.get("kind") == "reader_session" else "公开创作", + "progress": item.get("progress") or 0, + "kind": item.get("kind"), + "targetHref": item.get("targetHref"), + "updatedAt": item.get("updatedAt"), + } + ) + return public_items + + def soul_profile( + self, + *, + user_id: str, + viewer_account_id: Optional[str] = None, + viewer_actor_id: Optional[str] = None, + ) -> Dict[str, Any]: + identity = self._resolve_identity_by_user_id(user_id) + if identity is None: + return { + "userId": "guest", + "displayName": "guest", + "avatar": "", + "readingMileage": 0, + "ifBranchTriggered": 0, + "todayFocus": 0, + "level": 1, + "dimensions": [{"label": label, "value": 0, "max": 100} for label in SOUL_DIMENSION_LABELS], + "preferences": {"genres": [], "styles": [], "privacyMode": "followers"}, + "recentSessions": [], + "viewerIsOwner": False, + "viewerHasFollowedAuthor": False, + } + actor_id = str(identity.get("actor_id") or "").strip() + account_id = str(identity.get("account_id") or actor_id).strip() + preferences = self.get_soul_preferences(actor_id=actor_id, account_id=account_id) + viewer_is_owner = bool( + (viewer_actor_id and viewer_actor_id == actor_id) + or (viewer_account_id and viewer_account_id == account_id) + ) + authorized_follower = False + if not viewer_is_owner and viewer_account_id: + viewer_follow_state = self._library_follow_state(account_id=viewer_account_id) + authorized_follower = actor_id in viewer_follow_state["author_ids"] + privacy_mode = str(preferences.get("privacy_mode") or "followers") + can_view_full = viewer_is_owner or authorized_follower or privacy_mode == "public" + state_bundle = self._canonical_library_state_bundle(account_id=account_id) + favorite_state = state_bundle["favorite_state"] + follow_state = state_bundle["follow_state"] + bookmark_state = state_bundle["bookmark_state"] + sessions = self._reader_session_items( + account_id=account_id, + favorite_ids_override=set(favorite_state["reader_session_ids"]), + followed_world_ids_override=set(follow_state["world_ids"]), + bookmark_summary_override=bookmark_state, + current_node_resolver=self._story_session_current_node_id, + ) + author_items = self._author_work_items( + account_id=account_id, + favorite_ids_override=set(favorite_state["author_work_ids"]), + followed_world_ids_override=set(follow_state["world_ids"]), + ) + continue_total = sum(self._reader_continue_counts_by_session(account_id=account_id).values()) + reading_mileage = sum(max(1, int(item.get("progress") or 0) // 10) for item in sessions) + recent_count = self._recent_activity_count(account_id=account_id) + merged_recent = sorted( + [ + { + "id": item.get("id"), + "title": item.get("title"), + "coverImage": item.get("coverImage") or "", + "branchName": "阅读进度", + "progress": item.get("progress") or 0, + "kind": item.get("kind"), + "targetHref": item.get("targetHref"), + "updatedAt": item.get("updatedAt"), + "currentNodeId": item.get("currentNodeId"), + "viewerHasBookmarkedCurrentNode": item.get("viewerHasBookmarkedCurrentNode"), + } + for item in sessions + ] + + [ + { + "id": item.get("id"), + "title": item.get("title"), + "coverImage": item.get("coverImage") or "", + "branchName": "创作草稿", + "progress": item.get("progress") or 0, + "kind": item.get("kind"), + "targetHref": item.get("targetHref"), + "updatedAt": item.get("updatedAt"), + } + for item in self._author_draft_activity_items(account_id=account_id) + ], + key=lambda item: _parse_timestamp(item.get("updatedAt")), + reverse=True, + )[:6] + recent_sessions = merged_recent if can_view_full else self._public_activity_feed(account_id=account_id, limit=3) + return { + "userId": account_id, + "displayName": str(identity.get("display_name") or actor_id or account_id), + "avatar": "", + "readingMileage": reading_mileage, + "ifBranchTriggered": continue_total, + "todayFocus": min(100, 20 * recent_count), + "level": max(1, 1 + ((reading_mileage + len(author_items) + recent_count) // 5)), + "dimensions": self._soul_dimensions(account_id=account_id), + "preferences": { + "genres": list(preferences.get("genres") or []) if can_view_full else [], + "styles": list(preferences.get("styles") or []) if can_view_full else [], + "privacyMode": privacy_mode, + }, + "recentSessions": recent_sessions, + "viewerIsOwner": viewer_is_owner, + "viewerHasFollowedAuthor": authorized_follower, + } + + def showcase_viewer_key(self, *, viewer_account_id: Optional[str], request_headers: Optional[Dict[str, Any]] = None, remote_host: Optional[str] = None) -> str: + if viewer_account_id: + return f"account::{viewer_account_id}" + headers = request_headers or {} + agent = str(headers.get("user-agent") or headers.get("User-Agent") or "guest").strip() or "guest" + host = str(remote_host or "local").strip() or "local" + return f"guest::{host}::{agent[:80]}" + + def showcase_interaction_maps( + self, + *, + world_ids: List[str], + viewer_account_id: Optional[str] = None, + ) -> Dict[str, Any]: + like_counts = self.repository.showcase_work_like_counts(world_ids=world_ids) + comment_counts = self.repository.showcase_work_comment_counts(world_ids=world_ids, status="published") + tip_totals = self.repository.showcase_work_tip_totals(world_ids=world_ids) + view_counts = self.repository.showcase_work_view_counts(world_ids=world_ids, event_type="view") + impression_counts = self.repository.showcase_work_view_counts(world_ids=world_ids, event_type="impression") + liked_world_ids = set() + if viewer_account_id: + liked_world_ids = { + str(item.get("world_id") or "") + for item in self.repository.list_showcase_work_likes(world_ids=world_ids, account_id=viewer_account_id) + if str(item.get("world_id") or "").strip() + } + return { + "like_counts": like_counts, + "comment_counts": comment_counts, + "tip_totals": tip_totals, + "view_counts": view_counts, + "impression_counts": impression_counts, + "liked_world_ids": liked_world_ids, + } + + def _showcase_latest_published_versions(self) -> List[Dict[str, Any]]: + latest_by_world: Dict[str, Dict[str, Any]] = {} + for item in self.repository.list_world_versions(status="published"): + world_id = str(item.get("world_id") or "").strip() + if not world_id or world_id in latest_by_world: + continue + try: + version = self.repository.get_world_version(str(item.get("world_version_id") or "")) + except KeyError: + continue + metadata = dict(dict(version.worldpack_json or {}).get("metadata") or {}) + if not _is_public_catalog_visible(metadata): + continue + latest_by_world[world_id] = dict(item) + return list(latest_by_world.values()) + + def track_showcase_view( + self, + *, + world_id: str, + world_version_id: str, + viewer_key: str, + account_id: Optional[str], + event_type: str, + ) -> Dict[str, Any]: + return self.repository.save_showcase_work_view( + { + "world_id": world_id, + "world_version_id": world_version_id, + "viewer_key": viewer_key, + "account_id": account_id, + "event_type": event_type, + } + ) + + def showcase_item_from_version( + self, + *, + version_summary: Dict[str, Any], + hot_rank: Optional[int], + interaction_maps: Dict[str, Any], + viewer_account_id: Optional[str], + ) -> Dict[str, Any]: + version = self.repository.get_world_version(str(version_summary["world_version_id"])) + worldpack = dict(version.worldpack_json or {}) + world_bible = dict(worldpack.get("world_bible") or {}) + metadata = dict(worldpack.get("metadata") or {}) + manifest = dict(version.manifest_json or {}) + world_id = str(version.world_id or "") + review_records = self.repository.list_review_records(asset_id=version.world_version_id) + latest_review = review_records[0] if review_records else {} + moderation_status = "published_live" if str(version.status or "") == "published" else str(version.status or "draft") + moderation_label = ( + "已发布" + if moderation_status == "published_live" + else str(latest_review.get("status") or moderation_status) + ) + author_user_id = str(manifest.get("author_id") or version.author_id or "").strip() + return { + "id": version.world_version_id, + "title": str(worldpack.get("title") or version.world_id), + "coverImage": self._world_cover_url(world_version_id=version.world_version_id), + "description": str(world_bible.get("premise") or ""), + "visibility": "public", + "authorName": str(author_user_id or "官方"), + "authorAvatar": "", + "authorUserId": author_user_id or None, + "authorProfileHref": f"/soul/{quote(author_user_id, safe='')}" if author_user_id else None, + "viewCount": int((interaction_maps.get("view_counts") or {}).get(world_id, 0)), + "impressionCount": int((interaction_maps.get("impression_counts") or {}).get(world_id, 0)), + "likeCount": int((interaction_maps.get("like_counts") or {}).get(world_id, 0)), + "commentCount": int((interaction_maps.get("comment_counts") or {}).get(world_id, 0)), + "tipAmount": int((interaction_maps.get("tip_totals") or {}).get(world_id, 0)), + "createdAt": str(version_summary.get("updated_at") or ""), + "isHotRanked": hot_rank is not None, + "hotRank": hot_rank, + "viewerHasLiked": world_id in set(interaction_maps.get("liked_world_ids") or set()) if viewer_account_id else False, + "moderationStatus": moderation_status, + "moderationLabel": moderation_label, + "claimSafeBand": metadata.get("claim_safe_band"), + "productReadyBand": metadata.get("product_ready_band"), + "longform500ProductReady": bool(dict(metadata.get("longform_500_product_readiness") or {}).get("ready", False)), + } + + def showcase_works( + self, + *, + sort_value: str, + page: int, + page_size: int, + viewer_account_id: Optional[str] = None, + ) -> List[Dict[str, Any]]: + normalized_sort = str(sort_value or "").strip() + if normalized_sort not in VALID_SHOWCASE_SORTS: + normalized_sort = "hot" + published_versions = self._showcase_latest_published_versions() + world_ids = [str(item.get("world_id") or "") for item in published_versions if str(item.get("world_id") or "").strip()] + interaction_maps = self.showcase_interaction_maps(world_ids=world_ids, viewer_account_id=viewer_account_id) + + def hot_score(version_summary: Dict[str, Any]) -> float: + world_id = str(version_summary.get("world_id") or "") + created_at = _parse_timestamp(version_summary.get("updated_at")) + freshness_days = max(1.0, (datetime.now(timezone.utc) - created_at).total_seconds() / (24 * 60 * 60)) + return ( + float((interaction_maps["like_counts"].get(world_id) or 0) * 5) + + float((interaction_maps["comment_counts"].get(world_id) or 0) * 3) + + float((interaction_maps["tip_totals"].get(world_id) or 0) * 0.1) + + float((interaction_maps["view_counts"].get(world_id) or 0)) + + (6.0 / freshness_days) + ) + + if normalized_sort == "new": + ordered = sorted(published_versions, key=lambda item: _parse_timestamp(item.get("updated_at")), reverse=True) + elif normalized_sort == "ongoing": + ordered = sorted( + published_versions, + key=lambda item: ( + max( + int((interaction_maps["comment_counts"].get(str(item.get("world_id") or "")) or 0)), + int((interaction_maps["tip_totals"].get(str(item.get("world_id") or "")) or 0)), + int((interaction_maps["view_counts"].get(str(item.get("world_id") or "")) or 0)), + ), + _parse_timestamp(item.get("updated_at")), + ), + reverse=True, + ) + else: + ordered = sorted(published_versions, key=hot_score, reverse=True) + + items = [ + self.showcase_item_from_version( + version_summary=item, + hot_rank=index + 1 if normalized_sort == "hot" else None, + interaction_maps=interaction_maps, + viewer_account_id=viewer_account_id, + ) + for index, item in enumerate(ordered) + ] + page_index = max(1, int(page or 1)) + per_page = max(1, min(100, int(page_size or 20))) + start = (page_index - 1) * per_page + end = start + per_page + return items[start:end] diff --git a/src/narrativeos/services/reader_generation_jobs.py b/src/narrativeos/services/reader_generation_jobs.py new file mode 100644 index 0000000..76de712 --- /dev/null +++ b/src/narrativeos/services/reader_generation_jobs.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + +from .analytics import AnalyticsService +from .sessions import ReaderContinueCommand, SessionService + + +READER_GENERATION_JOB_TYPE = "reader_generation" + + +def compact_reader_generation_result(result: Dict[str, Any]) -> Dict[str, Any]: + operation = str(result.get("operation") or "") + reader_result = dict(result.get("reader_result") or {}) + chapter_view = dict(reader_result.get("chapter_view") or {}) + reader_view = dict(reader_result.get("reader_view") or {}) + chapter_index = chapter_view.get("chapterIndex") + if chapter_index is None: + updated_state = reader_result.get("updated_state") + if isinstance(updated_state, dict): + chapter_index = updated_state.get("chapter_index") + chapter_title = chapter_view.get("chapterTitle") or reader_view.get("chapter_title") + summary = { + "operation": operation, + "session_id": result.get("session_id") or reader_result.get("session_id"), + "world_id": result.get("world_id") or reader_result.get("world_id"), + "world_version_id": result.get("world_version_id") or reader_result.get("world_version_id"), + "reader_id": result.get("reader_id") or reader_result.get("reader_id"), + "reader_status": str(reader_result.get("status") or result.get("reader_status") or "ok"), + "chapter": { + "chapter_id": chapter_view.get("chapterId"), + "chapter_index": chapter_index, + "chapter_title": chapter_title, + }, + "paywall": reader_result.get("paywall"), + "quality_gate": reader_result.get("quality_gate"), + "continuity_contract": reader_result.get("continuity_contract"), + "quality_trace_id": reader_result.get("quality_trace_id"), + "bootstrap_attempts": result.get("bootstrap_attempts"), + "final_intent": result.get("final_intent"), + } + return {key: value for key, value in summary.items() if value is not None} + + +class ReaderGenerationJobRunner: + def __init__( + self, + *, + repository: Any, + session_service: SessionService, + analytics_service: Optional[AnalyticsService] = None, + ) -> None: + self.repository = repository + self.session_service = session_service + self.analytics = analytics_service + + def run(self, job: Dict[str, Any]) -> Dict[str, Any]: + payload = dict(job.get("payload") or {}) + operation = str(payload.get("operation") or "").strip() + session_id = str(payload.get("session_id") or "").strip() + reader_id = str(payload.get("reader_id") or "").strip() or None + if not session_id: + return { + "_job_status_override": "failed", + "operation": operation, + "session_id": session_id, + "error": "reader_generation_session_required", + } + if operation == "story_import_bootstrap": + return self._run_bootstrap(session_id=session_id, reader_id=reader_id, operation=operation) + if operation in {"reader_continue", "story_choice"}: + return self._run_continue( + operation=operation, + session_id=session_id, + reader_id=reader_id, + choice_id=payload.get("choice_id"), + freeform_intent=payload.get("freeform_intent"), + steering_directive=payload.get("steering_directive"), + ) + return { + "_job_status_override": "failed", + "operation": operation, + "session_id": session_id, + "reader_id": reader_id, + "error": f"unsupported_reader_generation_operation:{operation or 'missing'}", + } + + def _track(self, event_name: str, **kwargs: Any) -> None: + if self.analytics is None: + return + self.analytics.track(event_name, **kwargs) + + def _run_bootstrap(self, *, session_id: str, reader_id: Optional[str], operation: str) -> Dict[str, Any]: + session_record = self.repository.get_session(session_id) + world_version_id = str((session_record.metadata or {}).get("world_version_id") or "") + world_version = self.repository.get_world_version(world_version_id) + intents = [ + "进入故事。", + "更稳地进入故事。", + "先从眼前局势切入。", + ] + latest_result: Dict[str, Any] = {} + first_attempt_status: Optional[str] = None + final_intent = intents[0] + attempts = [] + for attempt_index, intent in enumerate(intents, start=1): + final_intent = intent + self._track( + "story_import_bootstrap_attempted", + reader_id=reader_id, + session_id=session_id, + world_id=world_version.world_id, + world_version_id=world_version_id, + payload_json={ + "attempt_index": attempt_index, + "bootstrap_intent": intent, + "async_job": True, + }, + ) + latest_result = self.session_service.continue_story( + ReaderContinueCommand(session_id=session_id, freeform_intent=intent), + reader_id=reader_id, + ) + status = str(latest_result.get("status") or "") + attempts.append({"attempt_index": attempt_index, "intent": intent, "status": status}) + if first_attempt_status is None: + first_attempt_status = status + if status == "quality_guard_failed" and intent != intents[-1]: + self._track( + "story_import_bootstrap_retry_applied", + reader_id=reader_id, + session_id=session_id, + world_id=world_version.world_id, + world_version_id=world_version_id, + payload_json={ + "attempt_index": attempt_index, + "bootstrap_intent": intent, + "result_status": status, + "recovered_after_retry": False, + "async_job": True, + }, + ) + if status != "quality_guard_failed": + break + final_status = str(latest_result.get("status") or "") + self._track( + "story_import_bootstrap_completed", + reader_id=reader_id, + session_id=session_id, + world_id=world_version.world_id, + world_version_id=world_version_id, + payload_json={ + "attempt_index": len(attempts), + "bootstrap_intent": final_intent, + "first_attempt_result_status": first_attempt_status or final_status, + "result_status": final_status, + "recovered_after_retry": ( + first_attempt_status == "quality_guard_failed" and final_status != "quality_guard_failed" + ), + "async_job": True, + }, + ) + return { + "operation": operation, + "session_id": session_id, + "reader_id": reader_id, + "world_id": world_version.world_id, + "world_version_id": world_version_id, + "reader_status": final_status or "ok", + "reader_result": latest_result, + "bootstrap_attempts": attempts, + "final_intent": final_intent, + } + + def _run_continue( + self, + *, + operation: str, + session_id: str, + reader_id: Optional[str], + choice_id: Any = None, + freeform_intent: Any = None, + steering_directive: Any = None, + ) -> Dict[str, Any]: + result = self.session_service.continue_story( + ReaderContinueCommand( + session_id=session_id, + choice_id=str(choice_id).strip() if choice_id else None, + freeform_intent=str(freeform_intent).strip() if freeform_intent else None, + steering_directive=dict(steering_directive or {}) if isinstance(steering_directive, dict) else None, + ), + reader_id=reader_id, + ) + return { + "operation": operation, + "session_id": session_id, + "reader_id": reader_id, + "world_id": result.get("world_id"), + "world_version_id": result.get("world_version_id"), + "reader_status": str(result.get("status") or "ok"), + "reader_result": result, + } diff --git a/src/narrativeos/services/reader_illustration_acceptance.py b/src/narrativeos/services/reader_illustration_acceptance.py new file mode 100644 index 0000000..2f638ba --- /dev/null +++ b/src/narrativeos/services/reader_illustration_acceptance.py @@ -0,0 +1,616 @@ +from __future__ import annotations + +import json +import os +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Dict, Mapping, Optional, Protocol +from urllib import error as urlerror +from urllib import request as urlrequest +from urllib.parse import urlencode, urljoin + + +REQUIRED_ILLUSTRATION_ENV_KEYS = ( + "OPENAI_API_KEY", + "NARRATIVEOS_IMAGE_MODEL", + "NARRATIVEOS_WORLD_BLOB_READ_WRITE_TOKEN", + "NARRATIVEOS_READER_BLOB_READ_WRITE_TOKEN", +) +def illustration_env_status(env: Optional[Mapping[str, str]] = None) -> Dict[str, Any]: + source = dict(os.environ if env is None else env) + missing_keys = [ + key + for key in REQUIRED_ILLUSTRATION_ENV_KEYS + if not str(source.get(key) or "").strip() + ] + vite_api_local = str(source.get("VITE_API_LOCAL") or "").strip().lower() == "true" + return { + "required_keys": list(REQUIRED_ILLUSTRATION_ENV_KEYS), + "missing_keys": missing_keys, + "vite_api_local": vite_api_local, + "database_url_present": bool(str(source.get("DATABASE_URL") or "").strip()), + "ready": not missing_keys and not vite_api_local, + } + + +class ReaderIllustrationAcceptanceTransport(Protocol): + def get_json(self, target: str, *, headers: Optional[Mapping[str, str]] = None) -> Dict[str, Any]: + ... + + def post_json( + self, + target: str, + payload: Mapping[str, Any], + *, + headers: Optional[Mapping[str, str]] = None, + ) -> Dict[str, Any]: + ... + + def get_binary(self, target: str, *, headers: Optional[Mapping[str, str]] = None) -> Dict[str, Any]: + ... + + +class UrllibReaderIllustrationAcceptanceTransport: + def __init__(self, *, base_url: str) -> None: + self.base_url = str(base_url or "").rstrip("/") + + def _full_url(self, target: str) -> str: + normalized = str(target or "").strip() + if normalized.startswith("http://") or normalized.startswith("https://"): + return normalized + if not normalized.startswith("/"): + normalized = f"/{normalized}" + return urljoin(f"{self.base_url}/", normalized.lstrip("/")) + + def _request( + self, + method: str, + target: str, + *, + headers: Optional[Mapping[str, str]] = None, + payload: Optional[Mapping[str, Any]] = None, + ) -> Dict[str, Any]: + data = None + request_headers = {"Accept": "application/json", **dict(headers or {})} + if payload is not None: + data = json.dumps(dict(payload)).encode("utf-8") + request_headers["Content-Type"] = "application/json" + req = urlrequest.Request( + self._full_url(target), + data=data, + headers=request_headers, + method=method.upper(), + ) + try: + with urlrequest.urlopen(req) as response: # noqa: S310 + raw_body = response.read() + status_code = int(response.status) + response_headers = dict(response.headers.items()) + except urlerror.HTTPError as exc: + raw_body = exc.read() + status_code = int(exc.code) + response_headers = dict(exc.headers.items()) + normalized_headers = {str(key).lower(): value for key, value in response_headers.items()} + content_type = str(normalized_headers.get("content-type") or "") + body_json: Any = None + if raw_body: + try: + body_json = json.loads(raw_body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + body_json = None + return { + "status_code": status_code, + "headers": response_headers, + "content_type": content_type, + "body_json": body_json, + "body_bytes": raw_body, + } + + def get_json(self, target: str, *, headers: Optional[Mapping[str, str]] = None) -> Dict[str, Any]: + response = self._request("GET", target, headers=headers) + return response + + def post_json( + self, + target: str, + payload: Mapping[str, Any], + *, + headers: Optional[Mapping[str, str]] = None, + ) -> Dict[str, Any]: + response = self._request("POST", target, headers=headers, payload=payload) + return response + + def get_binary(self, target: str, *, headers: Optional[Mapping[str, str]] = None) -> Dict[str, Any]: + response = self._request("GET", target, headers=headers) + return response + + +@dataclass +class ReaderIllustrationAcceptanceConfig: + backend_url: str = "http://127.0.0.1:8013" + world_id: str = "jade_court_exam" + username: Optional[str] = None + email: Optional[str] = None + password: str = "secret123" + timeout_seconds: Optional[float] = None + cover_timeout_seconds: float = 60.0 + hero_timeout_seconds: float = 180.0 + poll_interval_seconds: float = 1.0 + + +class ReaderIllustrationAcceptanceError(RuntimeError): + def __init__(self, reason: str, *, summary: Optional[Dict[str, Any]] = None) -> None: + super().__init__(reason) + self.reason = reason + self.summary = dict(summary or {}) + + +def _require_http_200(response: Dict[str, Any], *, step: str) -> Dict[str, Any]: + status_code = int(response.get("status_code") or 0) + if status_code != 200: + raise ReaderIllustrationAcceptanceError(f"{step}:http_{status_code}") + return response + + +def _require_success_envelope(response: Dict[str, Any], *, step: str) -> Any: + payload = dict(_require_http_200(response, step=step).get("body_json") or {}) + if int(payload.get("code") or 0) != 200: + raise ReaderIllustrationAcceptanceError( + f"{step}:unexpected_envelope:{payload.get('code')}:{payload.get('message')}" + ) + return payload.get("data") + + +def _auth_headers(token: str) -> Dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _unique_identity(*, username: Optional[str], email: Optional[str]) -> tuple[str, str]: + if username and email: + return str(username), str(email) + stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + suffix = str(int(time.time() * 1000))[-6:] + resolved_username = str(username or f"reader_illustration_{stamp}_{suffix}") + resolved_email = str(email or f"{resolved_username}@example.com") + return resolved_username, resolved_email + + +def _register_quantum_user( + transport: ReaderIllustrationAcceptanceTransport, + *, + username: str, + email: str, + password: str, +) -> Dict[str, Any]: + payload = _require_success_envelope( + transport.post_json( + "/api/v1/auth/register", + { + "username": username, + "email": email, + "password": password, + "displayName": username, + }, + ), + step="auth_register", + ) + token = str(payload.get("token") or "").strip() + if not token: + raise ReaderIllustrationAcceptanceError("auth_register:missing_token") + return { + "token": token, + "user": dict(payload.get("user") or {}), + } + + +def _find_public_work(items: list[Dict[str, Any]], *, world_id: str) -> Dict[str, Any]: + for item in items: + if str(item.get("worldId") or "").strip() == world_id: + return item + raise ReaderIllustrationAcceptanceError(f"public_works:world_missing:{world_id}") + + +def _effective_timeout( + *, + stage_timeout_seconds: float, + legacy_timeout_seconds: Optional[float], +) -> float: + if legacy_timeout_seconds is not None: + return float(legacy_timeout_seconds) + return float(stage_timeout_seconds) + + +def _poll_story_session( + transport: ReaderIllustrationAcceptanceTransport, + *, + session_id: str, + token: str, + required_field: str, + timeout_seconds: float, + poll_interval_seconds: float, +) -> Dict[str, Any]: + deadline = time.monotonic() + float(timeout_seconds or 0.0) + latest: Dict[str, Any] = {} + while time.monotonic() < deadline: + latest = _require_success_envelope( + transport.get_json( + f"/api/v1/story/session/{session_id}", + headers=_auth_headers(token), + ), + step=f"story_session:{required_field}", + ) + if str(latest.get(required_field) or "").strip(): + return { + "status": "ready", + "session": latest, + "required_field": required_field, + } + time.sleep(max(0.1, float(poll_interval_seconds or 0.1))) + return { + "status": "timeout", + "session": latest, + "required_field": required_field, + } + + +def _assert_image_response( + transport: ReaderIllustrationAcceptanceTransport, + *, + image_url: str, + step: str, +) -> Dict[str, Any]: + normalized = str(image_url or "").strip() + if not normalized: + raise ReaderIllustrationAcceptanceError(f"{step}:missing_url") + response = _require_http_200(transport.get_binary(normalized), step=step) + content_type = str(response.get("content_type") or "") + if not content_type.startswith("image/"): + raise ReaderIllustrationAcceptanceError(f"{step}:unexpected_content_type:{content_type}") + body_bytes = bytes(response.get("body_bytes") or b"") + if not body_bytes: + raise ReaderIllustrationAcceptanceError(f"{step}:empty_body") + return { + "url": normalized, + "content_type": content_type, + "byte_length": len(body_bytes), + } + + +def _story_choice_phase( + transport: ReaderIllustrationAcceptanceTransport, + *, + session_id: str, + token: str, + current_node_id: str, +) -> Dict[str, Any]: + choices = _require_success_envelope( + transport.get_json( + f"/api/v1/story/session/{session_id}/choices?{urlencode({'nodeId': current_node_id})}", + headers=_auth_headers(token), + ), + step="story_choices", + ) + if not choices: + raise ReaderIllustrationAcceptanceError("story_choices:empty") + first_choice = dict(list(choices)[0] or {}) + selected_choice_id = str(first_choice.get("id") or "") + response = transport.post_json( + "/api/v1/story/choice", + { + "sessionId": session_id, + "choiceId": selected_choice_id, + "nodeId": str(current_node_id or ""), + }, + headers=_auth_headers(token), + ) + status_code = int(response.get("status_code") or 0) + body_json = dict(response.get("body_json") or {}) + if status_code == 200: + chosen = dict(body_json.get("data") or {}) + if not str(chosen.get("id") or "").strip(): + raise ReaderIllustrationAcceptanceError("story_choice:missing_new_node") + return { + "attempted": True, + "status": "ok", + "separate_issue": False, + "http_status": status_code, + "selected_choice_id": selected_choice_id, + "new_node_id": str(chosen.get("id") or ""), + "quality_gate": None, + "continuity_contract": None, + } + if status_code == 402: + payload = dict(body_json.get("data") or {}) + return { + "attempted": True, + "status": "payment_required", + "separate_issue": True, + "http_status": status_code, + "selected_choice_id": selected_choice_id, + "new_node_id": None, + "quality_gate": None, + "continuity_contract": payload.get("continuityContract"), + } + if status_code == 409 and str(body_json.get("message") or "") == "story_reader_quality_guard_failed": + payload = dict(body_json.get("data") or {}) + return { + "attempted": True, + "status": "quality_guard_failed", + "separate_issue": True, + "http_status": status_code, + "selected_choice_id": selected_choice_id, + "new_node_id": None, + "quality_gate": payload.get("qualityGate"), + "continuity_contract": payload.get("continuityContract"), + } + raise ReaderIllustrationAcceptanceError( + f"story_choice:http_{status_code}", + summary={ + "story_choice": { + "attempted": True, + "status": "unexpected_http_error", + "separate_issue": False, + "http_status": status_code, + "selected_choice_id": selected_choice_id, + "quality_gate": dict((body_json.get("data") or {}).get("qualityGate") or {}) + if isinstance(body_json.get("data"), dict) + else None, + "continuity_contract": dict((body_json.get("data") or {}).get("continuityContract") or {}) + if isinstance(body_json.get("data"), dict) + else None, + } + }, + ) + + +def run_reader_illustration_acceptance( + transport: ReaderIllustrationAcceptanceTransport, + *, + config: Optional[ReaderIllustrationAcceptanceConfig] = None, +) -> Dict[str, Any]: + resolved = config or ReaderIllustrationAcceptanceConfig() + cover_timeout_seconds = _effective_timeout( + stage_timeout_seconds=resolved.cover_timeout_seconds, + legacy_timeout_seconds=resolved.timeout_seconds, + ) + hero_timeout_seconds = _effective_timeout( + stage_timeout_seconds=resolved.hero_timeout_seconds, + legacy_timeout_seconds=resolved.timeout_seconds, + ) + username, email = _unique_identity(username=resolved.username, email=resolved.email) + health_response = _require_http_200( + transport.get_json("/api/v1/health"), + step="api_health", + ) + health_payload = dict(health_response.get("body_json") or {}) + if str(health_payload.get("status") or "") != "ok": + raise ReaderIllustrationAcceptanceError("api_health:status_not_ok") + + public_works_before = _require_success_envelope( + transport.get_json("/api/v1/story/import/public-works"), + step="story_import_public_works_before", + ) + world_before = _find_public_work( + [dict(item or {}) for item in list(public_works_before or [])], + world_id=resolved.world_id, + ) + + auth_bundle = _register_quantum_user( + transport, + username=username, + email=email, + password=resolved.password, + ) + token = str(auth_bundle["token"]) + + launch = _require_success_envelope( + transport.post_json( + "/api/v1/story/import/start", + {"targetType": "world", "targetId": resolved.world_id}, + headers=_auth_headers(token), + ), + step="story_import_start", + ) + session_id = str(launch.get("sessionId") or "").strip() + world_version_id = str(launch.get("worldVersionId") or "").strip() + if not session_id: + raise ReaderIllustrationAcceptanceError("story_import_start:missing_session_id") + + summary: Dict[str, Any] = { + "healthy": False, + "illustration_healthy": False, + "backend_url": str(resolved.backend_url or "").rstrip("/"), + "world_id": resolved.world_id, + "world_version_id": world_version_id, + "username": username, + "email": email, + "session_id": session_id, + "illustration_phase": { + "status": "running", + "cover": None, + "hero": None, + "library_recent": None, + "public_works": None, + "showcase": None, + }, + "story_choice": { + "attempted": False, + "status": "not_attempted", + "separate_issue": False, + "http_status": None, + "selected_choice_id": None, + "new_node_id": None, + "quality_gate": None, + "continuity_contract": None, + }, + "separate_issues": [], + } + + cover_poll = _poll_story_session( + transport, + session_id=session_id, + token=token, + required_field="coverImage", + timeout_seconds=cover_timeout_seconds, + poll_interval_seconds=resolved.poll_interval_seconds, + ) + session_with_cover = dict(cover_poll.get("session") or {}) + if cover_poll.get("status") != "ready": + summary["illustration_phase"]["status"] = "waiting_on_cover" + summary["illustration_phase"]["cover"] = { + "status": "timeout", + "url": str(session_with_cover.get("coverImage") or ""), + } + raise ReaderIllustrationAcceptanceError( + f"story_session:coverImage:timeout:{session_id}", + summary=summary, + ) + session_cover = _assert_image_response( + transport, + image_url=str(session_with_cover.get("coverImage") or ""), + step="session_cover", + ) + summary["session_cover"] = session_cover + summary["illustration_phase"]["cover"] = { + "status": "ready", + **session_cover, + } + + hero_poll = _poll_story_session( + transport, + session_id=session_id, + token=token, + required_field="atmosphereImage", + timeout_seconds=hero_timeout_seconds, + poll_interval_seconds=resolved.poll_interval_seconds, + ) + session_with_hero = dict(hero_poll.get("session") or session_with_cover) + if hero_poll.get("status") != "ready": + summary["illustration_phase"]["status"] = "waiting_on_hero" + summary["illustration_phase"]["hero"] = { + "status": "timeout", + "url": str(session_with_hero.get("atmosphereImage") or ""), + } + raise ReaderIllustrationAcceptanceError( + f"story_session:atmosphereImage:timeout:{session_id}", + summary=summary, + ) + atmosphere_image = str(session_with_hero.get("atmosphereImage") or "") + if "/api/v1/media/assets/" not in atmosphere_image: + summary["illustration_phase"]["status"] = "hero_route_invalid" + summary["illustration_phase"]["hero"] = { + "status": "invalid_route", + "url": atmosphere_image, + } + raise ReaderIllustrationAcceptanceError( + "chapter_hero:expected_private_media_route", + summary=summary, + ) + chapter_hero = _assert_image_response( + transport, + image_url=atmosphere_image, + step="chapter_hero", + ) + summary["chapter_hero"] = chapter_hero + summary["illustration_phase"]["hero"] = { + "status": "ready", + **chapter_hero, + } + + recent_library_items = _require_success_envelope( + transport.get_json( + "/api/v1/library/works?filter=recent", + headers=_auth_headers(token), + ), + step="library_recent", + ) + library_item = next( + ( + dict(item or {}) + for item in list(recent_library_items or []) + if str(item.get("id") or "") == session_id + or session_id in str(item.get("targetHref") or "") + ), + None, + ) + if library_item is None: + summary["illustration_phase"]["status"] = "library_recent_missing" + raise ReaderIllustrationAcceptanceError("library_recent:session_item_missing", summary=summary) + if not str(library_item.get("coverImage") or "").strip(): + summary["illustration_phase"]["status"] = "library_recent_cover_missing" + raise ReaderIllustrationAcceptanceError("library_recent:cover_missing", summary=summary) + summary["library_recent_cover_image"] = str(library_item.get("coverImage") or "") + summary["illustration_phase"]["library_recent"] = { + "status": "ready", + "cover_image": str(library_item.get("coverImage") or ""), + } + + public_works_after = _require_success_envelope( + transport.get_json("/api/v1/story/import/public-works"), + step="story_import_public_works_after", + ) + world_after = _find_public_work( + [dict(item or {}) for item in list(public_works_after or [])], + world_id=resolved.world_id, + ) + if not str(world_after.get("coverImage") or "").strip(): + summary["illustration_phase"]["status"] = "public_work_cover_missing" + raise ReaderIllustrationAcceptanceError("public_works:cover_missing", summary=summary) + world_cover = _assert_image_response( + transport, + image_url=str(world_after.get("coverImage") or ""), + step="world_cover", + ) + summary["public_work_cover_before"] = str(world_before.get("coverImage") or "") + summary["public_work_cover_after"] = str(world_after.get("coverImage") or "") + summary["world_cover"] = world_cover + summary["illustration_phase"]["public_works"] = { + "status": "ready", + **world_cover, + } + + showcase_items = _require_success_envelope( + transport.get_json("/api/v1/showcase/works?sort=hot&page=1&pageSize=20"), + step="showcase_works", + ) + showcase_item = next( + ( + dict(item or {}) + for item in list(showcase_items or []) + if str(item.get("id") or "").strip() == world_version_id + ), + None, + ) + if showcase_item is None: + summary["illustration_phase"]["status"] = "showcase_missing" + raise ReaderIllustrationAcceptanceError( + f"showcase_works:world_version_missing:{world_version_id}", + summary=summary, + ) + if not str(showcase_item.get("coverImage") or "").strip(): + summary["illustration_phase"]["status"] = "showcase_cover_missing" + raise ReaderIllustrationAcceptanceError("showcase_works:cover_missing", summary=summary) + summary["showcase_cover_image"] = str(showcase_item.get("coverImage") or "") + summary["illustration_phase"]["showcase"] = { + "status": "ready", + "cover_image": str(showcase_item.get("coverImage") or ""), + } + summary["illustration_phase"]["status"] = "ok" + summary["illustration_healthy"] = True + + story_choice = _story_choice_phase( + transport, + session_id=session_id, + token=token, + current_node_id=str(session_with_hero.get("currentNodeId") or session_with_cover.get("currentNodeId") or ""), + ) + summary["story_choice"] = story_choice + if story_choice.get("separate_issue"): + if story_choice["status"] == "quality_guard_failed": + summary["separate_issues"].append("story_quality_guard") + elif story_choice["status"] == "payment_required": + summary["separate_issues"].append("story_payment_required") + + summary["healthy"] = bool(summary["illustration_healthy"]) + return summary 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/review.py b/src/narrativeos/services/review.py index 23f9b33..5d9394d 100644 --- a/src/narrativeos/services/review.py +++ b/src/narrativeos/services/review.py @@ -1,26 +1,53 @@ from __future__ import annotations +from datetime import datetime, timezone import json +from pathlib import Path from typing import Any, Dict, List, Optional +from ..benchmark.content_quality_contract_gate import evaluate_content_quality_contract_gate +from ..content_quality_strategy_execution import ( + build_strategy_bundle_batch_validation_trend, + list_strategy_bundle_batch_validation_history, +) from ..persistence.repositories import SQLAlchemyPlatformRepository +from ..quality.adapter import record_publish_preflight_quality_event +from ..benchmark.release_quality_gate import evaluate_release_quality_gate from .analytics import AnalyticsService +from .longform_capability import band_rank, build_longform_capability_payload +from .ops_quality_projection import OpsQualityProjectionService +from .reader_storybook_title_homogenization import ( + build_reader_storybook_title_homogenization_trend, + load_reader_storybook_title_homogenization_history, + promoted_reader_storybook_title_homogenization_pairs_for_world, + summarize_reader_storybook_title_homogenization_history, +) class ReviewService: - def __init__(self, repository: SQLAlchemyPlatformRepository, analytics_service: Optional[AnalyticsService] = None) -> None: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + analytics_service: Optional[AnalyticsService] = None, + quality_projection_service: Optional[OpsQualityProjectionService] = None, + ) -> None: self.repository = repository self.analytics = analytics_service or AnalyticsService(repository) + self.quality_projection = quality_projection_service or OpsQualityProjectionService(repository) + self.base_dir = Path(__file__).resolve().parents[3] + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() def _publish_gate_errors(self, simulation: Dict[str, Any]) -> List[str]: errors: List[str] = [] + if not simulation: + errors.append("publish_requires_simulation_report") + return errors evaluation_summary = dict(simulation.get("evaluation_summary", {})) cross_pack_summary = dict(simulation.get("cross_pack_summary", {})) delta_summary = dict(cross_pack_summary.get("delta_summary", {})) regressions = list(delta_summary.get("regressions", [])) - if not simulation: - errors.append("publish_requires_simulation_report") - return errors if not cross_pack_summary: errors.append("missing_cross_pack_summary") if simulation.get("latest_decision") == "block" or evaluation_summary.get("block_rate", 0.0) > 0.0: @@ -31,6 +58,14 @@ def _publish_gate_errors(self, simulation: Dict[str, Any]) -> List[str]: errors.append("cross_pack_pass_rate_regressed") if regressions: errors.append("metric_regression_detected") + quality_gate = dict(cross_pack_summary.get("phase_a_quality_gate") or simulation.get("phase_a_quality_gate") or evaluate_release_quality_gate(cross_pack_summary or simulation)) + errors.extend(str(item) for item in quality_gate.get("failed_checks", [])) + content_quality_gate = dict( + cross_pack_summary.get("content_quality_contract_gate") + or simulation.get("content_quality_contract_gate") + or evaluate_content_quality_contract_gate(cross_pack_summary or simulation) + ) + errors.extend(str(item) for item in content_quality_gate.get("failed_checks", [])) return errors def _review_note( @@ -87,21 +122,31 @@ def _checklist_item( severity: str, next_action: str, evidence: Dict[str, Any], + review_status: Optional[str] = None, ) -> Dict[str, Any]: return { "key": key, "label": label, "ok": ok, + "review_status": review_status or ("ready" if ok else "blocked"), "reason": reason, "source": source, "owner": owner, "severity": "info" if ok else severity, - "next_action": "none" if ok else next_action, + "next_action": ( + "none" + if ok and (review_status or ("ready" if ok else "blocked")) == "ready" + else next_action + ), "evidence": evidence, } def _publish_checklist_summary(self, checklist: List[Dict[str, Any]]) -> Dict[str, Any]: blocked = [item for item in checklist if not item.get("ok")] + status_counts: Dict[str, int] = {} + for item in checklist: + status = str(item.get("review_status") or ("ready" if item.get("ok") else "blocked")) + status_counts[status] = status_counts.get(status, 0) + 1 return { "total": len(checklist), "ok_count": sum(1 for item in checklist if item.get("ok")), @@ -110,8 +155,405 @@ def _publish_checklist_summary(self, checklist: List[Dict[str, Any]]) -> Dict[st "blocker_keys": [item.get("key") for item in blocked], "owners": sorted({str(item.get("owner")) for item in checklist if item.get("owner")}), "next_actions": [item.get("next_action") for item in blocked if item.get("next_action") and item.get("next_action") != "none"], + "review_status_counts": status_counts, + } + + def _artifact_candidate(self, *relative_paths: str) -> Optional[Dict[str, str]]: + base_dir = Path(__file__).resolve().parents[3] + for relative_path in relative_paths: + path = base_dir / relative_path + if path.exists(): + markdown_path = path.with_suffix(".md") if path.suffix == ".json" else None + return { + "json": str(path) if path.suffix == ".json" else "", + "markdown": str(markdown_path) if markdown_path and markdown_path.exists() else "", + } + return None + + def _artifact_metrics(self, artifact_ref: Optional[Dict[str, str]]) -> Dict[str, Any]: + json_path = str((artifact_ref or {}).get("json") or "") + if not json_path: + return {} + try: + payload = json.loads(Path(json_path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return payload if isinstance(payload, dict) else {} + + def _summarize_strategy_bundle_batch_validation( + self, + batch_validation: Dict[str, Any], + ) -> Dict[str, Any]: + payload = dict(batch_validation or {}) + return { + "available": bool(payload.get("available", False)), + "strategy_bundle_id": str(payload.get("strategy_bundle_id") or ""), + "strategy_bundle_label": str(payload.get("strategy_bundle_label") or ""), + "validated_world_count": int(payload.get("validated_world_count", 0) or 0), + "effectiveness_rate": self._safe_float(payload.get("effectiveness_rate")), + "decision": str(payload.get("decision") or ""), + "decision_reason": str(payload.get("decision_reason") or ""), + "top_adaptation_targets": [ + dict(item or {}) + for item in list(payload.get("adaptation_targets") or [])[:3] + ], + "compatible_world_ids": [str(item) for item in list(payload.get("compatible_world_ids") or []) if str(item)], + } + + def _summarize_strategy_bundle_batch_validation_history( + self, + history_payload: Dict[str, Any], + trend_payload: Dict[str, Any], + ) -> Dict[str, Any]: + history = dict(history_payload or {}) + trend = dict(trend_payload or {}) + return { + "available": bool(history.get("available", False) or trend.get("available", False)), + "strategy_bundle_id": str(trend.get("strategy_bundle_id") or history.get("strategy_bundle_id") or ""), + "recent_run_count": int(trend.get("recent_run_count", 0) or 0), + "latest_decision": str(trend.get("latest_decision") or ""), + "latest_effectiveness_rate": self._safe_float(trend.get("latest_effectiveness_rate")), + "delta_effectiveness_rate": self._safe_float(trend.get("delta_effectiveness_rate")), + "trend_status": str(trend.get("trend_status") or ""), + "trend_reason": str(trend.get("trend_reason") or ""), + "retire_recommended": bool(trend.get("retire_recommended", False)), + } + + def _reader_storybook_title_homogenization_release_evidence( + self, + *, + world_id: str, + ) -> Dict[str, Any]: + history_payload = load_reader_storybook_title_homogenization_history( + base_dir=self.base_dir + ) + trend_payload = build_reader_storybook_title_homogenization_trend(history_payload) + history_summary = summarize_reader_storybook_title_homogenization_history( + history_payload, + trend_payload, + ) + promoted_pairs = promoted_reader_storybook_title_homogenization_pairs_for_world( + trend_payload, + world_id=world_id, + ) + return { + "reader_storybook_title_homogenization_history_summary": history_summary, + "reader_storybook_title_homogenization_trend": trend_payload, + "reader_storybook_title_homogenization_promoted_pairs": promoted_pairs, + } + + def _build_longform_500_release_evidence_bundle(self, cross_pack_summary: Dict[str, Any]) -> Dict[str, Any]: + static_signoff = dict(cross_pack_summary.get("longform_500_signoff", {})) + interactive_signoff = dict(cross_pack_summary.get("longform_500_interactive_signoff", {})) + human_closeout = dict(cross_pack_summary.get("longform_500_human_review_closeout", {})) + ending_signoff = dict(cross_pack_summary.get("longform_500_ending_signoff", {})) + review_sample_coverage_500 = dict(cross_pack_summary.get("review_sample_coverage_500", {})) + + static_artifact = self._artifact_candidate( + "artifacts/longform/longform_500_rerun_all_v5_aggregated.json", + "artifacts/longform/longform_500_rerun_all_v3.json", + "artifacts/longform/longform_500_rerun_all_v5.json", + ) + bundle_artifact = self._artifact_candidate( + "artifacts/longform/longform_500_release_evidence_bundle.json", + ) + interactive_artifact = self._artifact_candidate( + "artifacts/longform/longform_500_interactive_rerun_all_aggregated.json", + ) + human_review_artifact = self._artifact_candidate( + "artifacts/longform/longform_500_human_review_execution.json", + ) + + static_metrics = self._artifact_metrics(static_artifact) + interactive_metrics = self._artifact_metrics(interactive_artifact) + human_metrics = self._artifact_metrics(human_review_artifact) + + required_components = { + "static_ready": bool(static_signoff.get("ready", False)), + "interactive_ready": bool(interactive_signoff.get("ready", False)), + "human_review_closeout_ready": bool(human_closeout.get("ready", False)), + "ending_signoff_ready": bool(ending_signoff.get("ready", False)), + } + combined_ready = all(required_components.values()) + blocking_worlds = sorted( + { + str(world_id) + for payload in [static_signoff, interactive_signoff, human_closeout, ending_signoff] + for world_id in list(payload.get("blocking_worlds", [])) + if str(world_id) + } + ) + watch_worlds = sorted( + { + str(world_id) + for payload in [static_signoff, interactive_signoff, human_closeout, ending_signoff] + for world_id in list(payload.get("watch_worlds", [])) + if str(world_id) and str(world_id) not in blocking_worlds + } + ) + return { + "generated_at": self._utcnow(), + "bundle_artifact": bundle_artifact or {}, + "static_artifact": static_artifact or {}, + "interactive_artifact": interactive_artifact or {}, + "human_review_artifact": human_review_artifact or {}, + "static_signoff": static_signoff, + "interactive_signoff": interactive_signoff, + "human_review_closeout": human_closeout, + "ending_signoff": ending_signoff, + "combined_signoff": { + "status": "ready" if combined_ready else "watch", + "ready": combined_ready, + "reason": "longform_500_release_bundle_ready" if combined_ready else "longform_500_release_bundle_watch", + "required_evidence": [ + "longform_500_signoff.ready", + "longform_500_interactive_signoff.ready", + "longform_500_human_review_closeout.ready", + "longform_500_ending_signoff.ready", + ], + "blocking_worlds": blocking_worlds, + "watch_worlds": watch_worlds, + }, + "summary_metrics": { + "static_cross_pack_pass_rate": static_metrics.get("cross_pack_pass_rate"), + "static_gate_pass_rate": dict(static_metrics.get("longform_500_summary", {})).get("gate_pass_rate"), + "interactive_cross_pack_pass_rate": interactive_metrics.get("cross_pack_pass_rate"), + "interactive_gate_pass_rate": dict(interactive_metrics.get("longform_500_interactive_summary", {})).get("gate_pass_rate"), + "human_reviewed_target_count": int(review_sample_coverage_500.get("human_reviewed_target_count", 0) or 0), + "ending_window_human_reviewed_count": int(review_sample_coverage_500.get("ending_window_human_reviewed_count", 0) or 0), + }, + "release_ready": combined_ready, + } + + def _build_longform_1000_release_evidence_bundle(self, cross_pack_summary: Dict[str, Any]) -> Dict[str, Any]: + static_signoff = dict(cross_pack_summary.get("longform_1000_readiness", {})) + interactive_signoff = dict(cross_pack_summary.get("longform_1000_interactive_signoff", {})) + human_closeout = dict(cross_pack_summary.get("longform_1000_human_review_closeout", {})) + feasibility = dict(cross_pack_summary.get("longform_1000_feasibility", {})) + review_sample_coverage_1000 = dict(cross_pack_summary.get("review_sample_coverage_1000", {})) + + static_artifact = self._artifact_candidate( + "artifacts/longform/longform_1000_diagnostics_all_v6_aggregated.json", + "artifacts/longform/longform_1000_diagnostics_all_v5_aggregated.json", + ) + bundle_artifact = self._artifact_candidate( + "artifacts/longform/longform_1000_release_evidence_bundle.json", + ) + interactive_artifact = self._artifact_candidate( + "artifacts/longform/longform_1000_interactive_rerun_all_aggregated.json", + ) + human_review_artifact = self._artifact_candidate( + "artifacts/longform/longform_1000_human_review_execution.json", + ) + + static_metrics = self._artifact_metrics(static_artifact) + interactive_metrics = self._artifact_metrics(interactive_artifact) + + required_components = { + "static_ready": bool(static_signoff.get("ready", False)), + "interactive_ready": bool(interactive_signoff.get("ready", False)), + "human_review_closeout_ready": bool(human_closeout.get("ready", False)), + } + combined_ready = all(required_components.values()) + blocking_worlds = sorted( + { + str(world_id) + for payload in [static_signoff, interactive_signoff, human_closeout] + for world_id in list(payload.get("blocking_worlds", [])) + if str(world_id) + } + ) + watch_worlds = sorted( + { + str(world_id) + for payload in [static_signoff, interactive_signoff, human_closeout] + for world_id in list(payload.get("watch_worlds", [])) + if str(world_id) and str(world_id) not in blocking_worlds + } + ) + return { + "generated_at": self._utcnow(), + "bundle_artifact": bundle_artifact or {}, + "static_artifact": static_artifact or {}, + "interactive_artifact": interactive_artifact or {}, + "human_review_artifact": human_review_artifact or {}, + "static_signoff": static_signoff, + "interactive_signoff": interactive_signoff, + "human_review_closeout": human_closeout, + "feasibility": feasibility, + "combined_signoff": { + "status": "ready" if combined_ready else "watch", + "ready": combined_ready, + "reason": "longform_1000_release_bundle_ready" if combined_ready else "longform_1000_release_bundle_watch", + "required_evidence": [ + "longform_1000_readiness.ready", + "longform_1000_interactive_signoff.ready", + "longform_1000_human_review_closeout.ready", + ], + "blocking_worlds": blocking_worlds, + "watch_worlds": watch_worlds, + }, + "summary_metrics": { + "static_cross_pack_pass_rate": static_metrics.get("cross_pack_pass_rate"), + "static_diagnostic_pass_rate": dict(static_metrics.get("longform_1000_summary", {})).get("diagnostic_pass_rate"), + "interactive_cross_pack_pass_rate": interactive_metrics.get("cross_pack_pass_rate"), + "interactive_gate_pass_rate": dict(interactive_metrics.get("longform_1000_interactive_summary", {})).get("gate_pass_rate"), + "human_reviewed_target_count": int(review_sample_coverage_1000.get("human_reviewed_target_count", 0) or 0), + "planned_target_count": int(review_sample_coverage_1000.get("planned_target_count", 0) or 0), + "feasibility_status": feasibility.get("status"), + }, + "release_ready": combined_ready, + } + + def _build_release_evidence_bundle(self, cross_pack_summary: Dict[str, Any]) -> Dict[str, Any]: + benchmark_mode = str(cross_pack_summary.get("benchmark_mode") or "") + longform_1000_readiness = dict(cross_pack_summary.get("longform_1000_readiness") or {}) + longform_1000_interactive_signoff = dict(cross_pack_summary.get("longform_1000_interactive_signoff") or {}) + longform_1000_human_review_closeout = dict(cross_pack_summary.get("longform_1000_human_review_closeout") or {}) + batch_validation = dict(cross_pack_summary.get("strategy_bundle_batch_validation") or {}) + resolved_strategy_bundle_id = str(batch_validation.get("strategy_bundle_id") or "").strip() + if not resolved_strategy_bundle_id: + resolved_strategy_bundle_id = str( + dict((cross_pack_summary.get("strategy_validation_summary") or {}).get("bundle_groups", [{}])[0]).get("strategy_bundle_id") or "" + ).strip() + batch_validation_history = dict(cross_pack_summary.get("strategy_bundle_batch_validation_history") or {}) + if not batch_validation_history and resolved_strategy_bundle_id: + batch_validation_history = list_strategy_bundle_batch_validation_history( + repository=self.repository, + strategy_bundle_id=resolved_strategy_bundle_id, + limit=5, + ) + batch_validation_trend = dict(cross_pack_summary.get("strategy_bundle_batch_validation_trend") or {}) + if not batch_validation_trend and resolved_strategy_bundle_id: + batch_validation_trend = build_strategy_bundle_batch_validation_trend(batch_validation_history) + if ( + benchmark_mode in {"longform_1000_diagnostics", "longform_1000_interactive"} + or bool(longform_1000_readiness.get("ready", False)) + or bool(longform_1000_interactive_signoff.get("ready", False)) + or bool(longform_1000_human_review_closeout.get("ready", False)) + ): + bundle = self._build_longform_1000_release_evidence_bundle(cross_pack_summary) + else: + bundle = self._build_longform_500_release_evidence_bundle(cross_pack_summary) + bundle["strategy_bundle_batch_validation"] = batch_validation + bundle["strategy_bundle_batch_validation_summary"] = self._summarize_strategy_bundle_batch_validation(batch_validation) + bundle["strategy_bundle_batch_validation_history"] = batch_validation_history + bundle["strategy_bundle_batch_validation_trend"] = batch_validation_trend + bundle["strategy_bundle_batch_validation_history_summary"] = self._summarize_strategy_bundle_batch_validation_history( + batch_validation_history, + batch_validation_trend, + ) + return bundle + + def _author_longform_capability(self, world_version: Optional[Any]) -> Dict[str, Any]: + if world_version is None: + return {} + metadata = dict((world_version.worldpack_json or {}).get("metadata") or {}) + if not ( + metadata.get("author_brief") + or metadata.get("entry_mode") + or metadata.get("claim_safe_band") + or metadata.get("requested_target_chapters") + or metadata.get("longform_readiness") + ): + return {} + return build_longform_capability_payload( + base_dir=self.base_dir, + repository=self.repository, + worldpack_payload=dict(world_version.worldpack_json or {}), + version=world_version, + ) + + def _ops_release_ready_band( + self, + *, + longform_signoff: Dict[str, Any], + interactive_longform_signoff: Dict[str, Any], + longform_250_signoff: Dict[str, Any], + longform_250_interactive_signoff: Dict[str, Any], + longform_250_human_review_closeout: Dict[str, Any], + longform_500_release_bundle: Dict[str, Any], + longform_1000_release_bundle: Dict[str, Any], + ) -> Optional[str]: + if bool(dict(longform_1000_release_bundle.get("combined_signoff") or {}).get("ready", False)): + return "1000" + if bool(dict(longform_500_release_bundle.get("combined_signoff") or {}).get("ready", False)): + return "500" + if ( + bool(longform_250_signoff.get("ready", False)) + and bool(longform_250_interactive_signoff.get("ready", False)) + and bool(longform_250_human_review_closeout.get("ready", False)) + ): + return "250" + if str(longform_signoff.get("status") or "") != "blocked" and str(interactive_longform_signoff.get("status") or "") != "blocked": + return "100" + return None + + def _author_claim_alignment( + self, + *, + author_capability: Dict[str, Any], + ops_release_ready_band: Optional[str], + ) -> Dict[str, Any]: + if not author_capability: + return { + "claim_safe_band": None, + "ops_release_ready_band": ops_release_ready_band, + "aligned": True, + "reason": "author_longform_claim_not_asserted", + "blocking_worlds": [], + } + claim_safe_band = str(author_capability.get("claim_safe_band") or "").strip() or None + if not claim_safe_band: + return { + "claim_safe_band": None, + "ops_release_ready_band": ops_release_ready_band, + "aligned": True, + "reason": "author_claim_safe_band_missing", + "blocking_worlds": [], + } + if not ops_release_ready_band: + return { + "claim_safe_band": claim_safe_band, + "ops_release_ready_band": None, + "aligned": False, + "reason": "ops_release_ready_band_missing", + "blocking_worlds": [], + } + aligned = band_rank(claim_safe_band) <= band_rank(ops_release_ready_band) + return { + "claim_safe_band": claim_safe_band, + "ops_release_ready_band": ops_release_ready_band, + "aligned": aligned, + "reason": "author_claim_aligned" if aligned else "author_claim_exceeds_ops_release_ready_band", + "blocking_worlds": [], } + def _augment_release_evidence_bundle( + self, + *, + release_evidence_bundle: Dict[str, Any], + author_capability: Dict[str, Any], + author_claim_alignment: Dict[str, Any], + ) -> Dict[str, Any]: + bundle = dict(release_evidence_bundle or {}) + combined_signoff = dict(bundle.get("combined_signoff") or {}) + required_evidence = list(combined_signoff.get("required_evidence") or []) + if "author_longform_claim_alignment.ready" not in required_evidence: + required_evidence.append("author_longform_claim_alignment.ready") + combined_ready = bool(combined_signoff.get("ready", False)) and bool(author_claim_alignment.get("aligned")) + bundle["author_longform_capability"] = author_capability + bundle["author_claim_alignment"] = author_claim_alignment + bundle["combined_signoff"] = { + **combined_signoff, + "ready": combined_ready, + "status": "ready" if combined_ready else "watch", + "reason": combined_signoff.get("reason") if author_claim_alignment.get("aligned") else str(author_claim_alignment.get("reason") or "author_claim_exceeds_ops_release_ready_band"), + "required_evidence": required_evidence, + } + bundle["release_ready"] = combined_ready + return bundle + def _review_timeline_entry(self, record: Dict[str, Any]) -> Dict[str, Any]: note_payload = parse_review_notes(record.get("notes")) return { @@ -307,7 +749,65 @@ def build_publish_checklist(self, world_version_id: str) -> List[Dict[str, Any]] evaluation_summary = dict(simulation.get("evaluation_summary", {})) cross_pack_summary = dict(simulation.get("cross_pack_summary", {})) delta_summary = dict(cross_pack_summary.get("delta_summary", {})) + longform_signoff = dict(cross_pack_summary.get("longform_l1_signoff", {})) + interactive_longform_signoff = dict(cross_pack_summary.get("interactive_longform_signoff", {})) + longform_250_signoff = dict(cross_pack_summary.get("longform_250_signoff", {})) + longform_250_interactive_signoff = dict(cross_pack_summary.get("longform_250_interactive_signoff", {})) + longform_250_human_review_closeout = dict(cross_pack_summary.get("longform_250_human_review_closeout", {})) + longform_500_signoff = dict(cross_pack_summary.get("longform_500_signoff", {})) + longform_500_interactive_signoff = dict(cross_pack_summary.get("longform_500_interactive_signoff", {})) + longform_1000_readiness = dict(cross_pack_summary.get("longform_1000_readiness", {})) + longform_1000_interactive_signoff = dict(cross_pack_summary.get("longform_1000_interactive_signoff", {})) + longform_1000_feasibility = dict(cross_pack_summary.get("longform_1000_feasibility", {})) + longform_1000_human_review_closeout = dict(cross_pack_summary.get("longform_1000_human_review_closeout", {})) + review_sample_coverage_250 = dict(cross_pack_summary.get("review_sample_coverage_250", {})) + longform_500_human_review_closeout = dict(cross_pack_summary.get("longform_500_human_review_closeout", {})) + longform_500_ending_signoff = dict(cross_pack_summary.get("longform_500_ending_signoff", {})) + review_sample_coverage_500 = dict(cross_pack_summary.get("review_sample_coverage_500", {})) + review_sample_coverage_1000 = dict(cross_pack_summary.get("review_sample_coverage_1000", {})) + has_longform_1000_contract = bool(cross_pack_summary.get("longform_1000_readiness") or cross_pack_summary.get("longform_1000_feasibility")) + longform_500_release_bundle = self._build_longform_500_release_evidence_bundle(cross_pack_summary) + longform_1000_release_bundle = self._build_longform_1000_release_evidence_bundle(cross_pack_summary) if has_longform_1000_contract else {} + author_capability = self._author_longform_capability(world_version) + ops_release_ready_band = self._ops_release_ready_band( + longform_signoff=longform_signoff, + interactive_longform_signoff=interactive_longform_signoff, + longform_250_signoff=longform_250_signoff, + longform_250_interactive_signoff=longform_250_interactive_signoff, + longform_250_human_review_closeout=longform_250_human_review_closeout, + longform_500_release_bundle=longform_500_release_bundle, + longform_1000_release_bundle=longform_1000_release_bundle, + ) + author_claim_alignment = self._author_claim_alignment( + author_capability=author_capability, + ops_release_ready_band=ops_release_ready_band, + ) + has_author_longform_contract = bool(author_capability) + character_fidelity_remediation_framework = dict(cross_pack_summary.get("character_fidelity_remediation_framework", {})) + reader_storybook_title_homogenization_evidence = ( + self._reader_storybook_title_homogenization_release_evidence( + world_id=world_version.world_id, + ) + ) + reader_storybook_title_homogenization_history_summary = dict( + reader_storybook_title_homogenization_evidence.get( + "reader_storybook_title_homogenization_history_summary" + ) + or {} + ) + reader_storybook_title_homogenization_promoted_pairs = list( + reader_storybook_title_homogenization_evidence.get( + "reader_storybook_title_homogenization_promoted_pairs" + ) + or [] + ) top_failing_pack_ids = self._pack_ids(cross_pack_summary.get("top_failing_packs", simulation.get("top_failing_packs", []))) + shared_quality_gate = dict(cross_pack_summary.get("phase_a_quality_gate") or simulation.get("phase_a_quality_gate") or evaluate_release_quality_gate(cross_pack_summary or simulation)) + content_quality_contract_gate = dict( + cross_pack_summary.get("content_quality_contract_gate") + or simulation.get("content_quality_contract_gate") + or evaluate_content_quality_contract_gate(cross_pack_summary or simulation) + ) leaking_worlds = [ { "world_id": item.get("world_id"), @@ -379,6 +879,36 @@ def build_publish_checklist(self, world_version_id: str) -> List[Dict[str, Any]] "regressions": list(delta_summary.get("regressions", [])), }, ), + self._checklist_item( + key="phase_a_quality_gate", + label="Phase A 共享质量门槛", + ok=bool(shared_quality_gate.get("ok", False)), + reason=( + "phase_a_quality_gate_met" + if shared_quality_gate.get("ok", False) + else str((shared_quality_gate.get("failed_checks") or ["phase_a_quality_gate_blocked"])[0]) + ), + source="release_quality_gate", + owner="benchmark_reporting", + severity="blocker", + next_action="inspect_phase_a_quality_gate_failures", + evidence=shared_quality_gate, + ), + self._checklist_item( + key="content_quality_contract_gate", + label="Content quality contract gate", + ok=bool(content_quality_contract_gate.get("ok", False)), + reason=( + "content_quality_contract_gate_met" + if content_quality_contract_gate.get("ok", False) + else str((content_quality_contract_gate.get("failed_checks") or ["content_quality_contract_gate_blocked"])[0]) + ), + source="content_quality_contract_gate", + owner="benchmark_reporting", + severity="blocker", + next_action="inspect_content_quality_contract_gate_failures", + evidence=content_quality_contract_gate, + ), self._checklist_item( key="chapter_eval_gate", label="章节评测未 block", @@ -395,7 +925,544 @@ def build_publish_checklist(self, world_version_id: str) -> List[Dict[str, Any]] "block_rate": evaluation_summary.get("block_rate"), }, ), - ] + self._checklist_item( + key="author_longform_readiness", + label="Author longform readiness", + ok=(not has_author_longform_contract) or dict(author_capability.get("longform_readiness") or {}).get("status") == "ready", + review_status=str(dict(author_capability.get("longform_readiness") or {}).get("status") or ("ready" if not has_author_longform_contract else "blocked")), + reason=str( + ((dict(author_capability.get("longform_readiness") or {}).get("blockers") or [{}])[0] or {}).get("key") + or ("author_longform_claim_not_asserted" if not has_author_longform_contract else "author_longform_readiness_blocked") + ), + source="author_longform_readiness", + owner="authoring_service", + severity="blocker", + next_action=str(((dict(author_capability.get("longform_readiness") or {}).get("recommended_actions") or ["focus_longform"])[0])), + evidence={ + "entry_mode": author_capability.get("entry_mode"), + "requested_target_band": author_capability.get("requested_target_band"), + "supported_target_band": author_capability.get("supported_target_band"), + "claim_safe_band": author_capability.get("claim_safe_band"), + "status": dict(author_capability.get("longform_readiness") or {}).get("status"), + "blockers": list(dict(author_capability.get("longform_readiness") or {}).get("blockers") or []), + "structure_counts": dict(author_capability.get("structure_counts") or {}), + }, + ), + self._checklist_item( + key="author_longform_claim_alignment", + label="Author claim aligns with ops release band", + ok=bool(author_claim_alignment.get("aligned")), + review_status="ready" if author_claim_alignment.get("aligned") else "blocked", + reason=str(author_claim_alignment.get("reason") or "author_claim_exceeds_ops_release_ready_band"), + source="author_longform_claim_alignment", + owner="ops_release", + severity="blocker", + next_action="inspect_release_evidence_bundle", + evidence={ + "entry_mode": author_capability.get("entry_mode"), + "requested_target_band": author_capability.get("requested_target_band"), + "claim_safe_band": author_claim_alignment.get("claim_safe_band"), + "ops_release_ready_band": author_claim_alignment.get("ops_release_ready_band"), + "aligned": bool(author_claim_alignment.get("aligned")), + "status": dict(author_capability.get("longform_readiness") or {}).get("status"), + "blockers": list(dict(author_capability.get("longform_readiness") or {}).get("blockers") or []), + }, + ), + self._checklist_item( + key="longform_l1_signoff", + label="Longform L1 sign-off", + ok=longform_signoff.get("status") != "blocked", + review_status=str(longform_signoff.get("status") or "watch"), + reason=str(longform_signoff.get("reason") or "longform_l1_signoff_missing"), + source="longform_l1_signoff", + owner="benchmark_reporting", + severity="high", + next_action=( + "rerun_longform_100_signoff" + if str(longform_signoff.get("status") or "watch") == "watch" + else "continue_lane_a_polish" + ), + evidence={ + "status": longform_signoff.get("status"), + "ready": bool(longform_signoff.get("ready", False)), + "blocking_worlds": list(longform_signoff.get("blocking_worlds", [])), + "watch_worlds": list(longform_signoff.get("watch_worlds", [])), + }, + ), + ] + ( + [ + self._checklist_item( + key="interactive_100_readiness", + label="Interactive 100 readiness", + ok=interactive_longform_signoff.get("status") != "blocked", + review_status=str(interactive_longform_signoff.get("status") or "watch"), + reason=str(interactive_longform_signoff.get("reason") or "interactive_longform_signoff_missing"), + source="interactive_longform_signoff", + owner="benchmark_reporting", + severity="high", + next_action=( + "rerun_longform_100_interactive_signoff" + if str(interactive_longform_signoff.get("status") or "watch") == "watch" + else "continue_interactive_lane_a_polish" + ), + evidence={ + "status": interactive_longform_signoff.get("status"), + "ready": bool(interactive_longform_signoff.get("ready", False)), + "blocking_worlds": list(interactive_longform_signoff.get("blocking_worlds", [])), + "watch_worlds": list(interactive_longform_signoff.get("watch_worlds", [])), + }, + ) + ] + if interactive_longform_signoff + else [] + ) + ( + [ + self._checklist_item( + key="longform_250_readiness", + label="Longform 250 readiness", + ok=True, + review_status=str(longform_250_signoff.get("status") or "watch"), + reason=str(longform_250_signoff.get("reason") or "longform_250_signoff_missing"), + source="longform_250_signoff", + owner="benchmark_reporting", + severity="info", + next_action=( + "execute_longform_250_review_sampling" + if not bool(review_sample_coverage_250.get("closeout_ready", False)) + else ( + "rerun_longform_250_benchmark" + if str(longform_250_signoff.get("status") or "watch") == "watch" + else "review_longform_250_evidence" + ) + ), + evidence={ + "status": longform_250_signoff.get("status"), + "ready": bool(longform_250_signoff.get("ready", False)), + "blocking_worlds": list(longform_250_signoff.get("blocking_worlds", [])), + "watch_worlds": list(longform_250_signoff.get("watch_worlds", [])), + "review_sample_closeout_status": review_sample_coverage_250.get("closeout_status"), + "review_sample_closeout_ready": bool(review_sample_coverage_250.get("closeout_ready", False)), + "executed_target_count": int(review_sample_coverage_250.get("executed_target_count", 0) or 0), + "planned_target_count": int(review_sample_coverage_250.get("planned_target_count", 0) or 0), + }, + ) + ] + if longform_250_signoff + else [] + ) + ( + [ + self._checklist_item( + key="interactive_250_readiness", + label="Interactive 250 readiness", + ok=True, + review_status=str(longform_250_interactive_signoff.get("status") or "watch"), + reason=str(longform_250_interactive_signoff.get("reason") or "longform_250_interactive_signoff_missing"), + source="longform_250_interactive_signoff", + owner="benchmark_reporting", + severity="info", + next_action=( + "rerun_longform_250_interactive_benchmark" + if str(longform_250_interactive_signoff.get("status") or "watch") == "watch" + else "review_longform_250_interactive_evidence" + ), + evidence={ + "status": longform_250_interactive_signoff.get("status"), + "ready": bool(longform_250_interactive_signoff.get("ready", False)), + "blocking_worlds": list(longform_250_interactive_signoff.get("blocking_worlds", [])), + "watch_worlds": list(longform_250_interactive_signoff.get("watch_worlds", [])), + }, + ) + ] + if longform_250_interactive_signoff + else [] + ) + ( + [ + self._checklist_item( + key="longform_250_human_review_closeout", + label="Longform 250 human-review closeout", + ok=True, + review_status=str(longform_250_human_review_closeout.get("status") or "watch"), + reason=str(longform_250_human_review_closeout.get("reason") or "longform_250_human_review_closeout_missing"), + source="longform_250_human_review_closeout", + owner="ops_review", + severity="info", + next_action=( + "capture_longform_250_human_reviews" + if str(longform_250_human_review_closeout.get("status") or "watch") == "watch" + else "review_longform_250_human_closeout" + ), + evidence={ + "status": longform_250_human_review_closeout.get("status"), + "ready": bool(longform_250_human_review_closeout.get("ready", False)), + "blocking_worlds": list(longform_250_human_review_closeout.get("blocking_worlds", [])), + "watch_worlds": list(longform_250_human_review_closeout.get("watch_worlds", [])), + "human_closeout_status": review_sample_coverage_250.get("human_closeout_status"), + "human_closeout_ready": bool(review_sample_coverage_250.get("human_closeout_ready", False)), + "human_reviewed_target_count": int(review_sample_coverage_250.get("human_reviewed_target_count", 0) or 0), + "planned_target_count": int(review_sample_coverage_250.get("planned_target_count", 0) or 0), + }, + ) + ] + if longform_250_human_review_closeout + else [] + ) + ( + [ + self._checklist_item( + key="longform_500_readiness", + label="Longform 500 readiness", + ok=True, + review_status=str(longform_500_signoff.get("status") or "watch"), + reason=str(longform_500_signoff.get("reason") or "longform_500_signoff_missing"), + source="longform_500_signoff", + owner="benchmark_reporting", + severity="info", + next_action=( + "rerun_longform_500_benchmark" + if str(longform_500_signoff.get("status") or "watch") == "watch" + else "review_longform_500_evidence" + ), + evidence={ + "status": longform_500_signoff.get("status"), + "ready": bool(longform_500_signoff.get("ready", False)), + "blocking_worlds": list(longform_500_signoff.get("blocking_worlds", [])), + "watch_worlds": list(longform_500_signoff.get("watch_worlds", [])), + }, + ) + ] + if longform_500_signoff + else [] + ) + ( + [ + self._checklist_item( + key="interactive_500_readiness", + label="Interactive 500 readiness", + ok=True, + review_status=str(longform_500_interactive_signoff.get("status") or "watch"), + reason=str(longform_500_interactive_signoff.get("reason") or "longform_500_interactive_signoff_missing"), + source="longform_500_interactive_signoff", + owner="benchmark_reporting", + severity="info", + next_action=( + "rerun_longform_500_interactive_benchmark" + if str(longform_500_interactive_signoff.get("status") or "watch") == "watch" + else "review_longform_500_interactive_evidence" + ), + evidence={ + "status": longform_500_interactive_signoff.get("status"), + "ready": bool(longform_500_interactive_signoff.get("ready", False)), + "blocking_worlds": list(longform_500_interactive_signoff.get("blocking_worlds", [])), + "watch_worlds": list(longform_500_interactive_signoff.get("watch_worlds", [])), + }, + ) + ] + if longform_500_interactive_signoff + else [] + ) + ( + [ + self._checklist_item( + key="longform_1000_readiness", + label="Longform 1000 readiness", + ok=True, + review_status=str(longform_1000_readiness.get("status") or "watch"), + reason=str(longform_1000_readiness.get("reason") or "longform_1000_readiness_missing"), + source="longform_1000_readiness", + owner="benchmark_reporting", + severity="info", + next_action=( + "rerun_longform_1000_diagnostics" + if str(longform_1000_readiness.get("status") or "watch") == "watch" + else "review_longform_1000_readiness" + ), + evidence={ + "status": longform_1000_readiness.get("status"), + "ready": bool(longform_1000_readiness.get("ready", False)), + "blocking_worlds": list(longform_1000_readiness.get("blocking_worlds", [])), + "watch_worlds": list(longform_1000_readiness.get("watch_worlds", [])), + }, + ) + ] + if longform_1000_readiness + else [] + ) + ( + [ + self._checklist_item( + key="longform_1000_feasibility", + label="Longform 1000 feasibility", + ok=True, + review_status=str(longform_1000_feasibility.get("status") or "watch"), + reason=str(longform_1000_feasibility.get("reason") or "longform_1000_feasibility_missing"), + source="longform_1000_feasibility", + owner="benchmark_reporting", + severity="info", + next_action=( + "rerun_longform_1000_diagnostics" + if str(longform_1000_feasibility.get("status") or "watch") == "watch" + else "review_longform_1000_feasibility" + ), + evidence={ + "status": longform_1000_feasibility.get("status"), + "ready": bool(longform_1000_feasibility.get("ready", False)), + "blocking_worlds": list(longform_1000_feasibility.get("blocking_worlds", [])), + "watch_worlds": list(longform_1000_feasibility.get("watch_worlds", [])), + "diagnostic_pass_rate": longform_1000_feasibility.get("diagnostic_pass_rate"), + }, + ) + ] + if longform_1000_feasibility + else [] + ) + ( + [ + self._checklist_item( + key="interactive_1000_readiness", + label="Interactive 1000 readiness", + ok=True, + review_status=str(longform_1000_interactive_signoff.get("status") or "watch"), + reason=str(longform_1000_interactive_signoff.get("reason") or "longform_1000_interactive_signoff_missing"), + source="longform_1000_interactive_signoff", + owner="benchmark_reporting", + severity="info", + next_action=( + "rerun_longform_1000_interactive_benchmark" + if str(longform_1000_interactive_signoff.get("status") or "watch") == "watch" + else "review_longform_1000_interactive_evidence" + ), + evidence={ + "status": longform_1000_interactive_signoff.get("status"), + "ready": bool(longform_1000_interactive_signoff.get("ready", False)), + "blocking_worlds": list(longform_1000_interactive_signoff.get("blocking_worlds", [])), + "watch_worlds": list(longform_1000_interactive_signoff.get("watch_worlds", [])), + }, + ) + ] + if longform_1000_interactive_signoff + else [] + ) + ( + [ + self._checklist_item( + key="longform_1000_human_review_closeout", + label="Longform 1000 human-review closeout", + ok=True, + review_status=str(longform_1000_human_review_closeout.get("status") or "watch"), + reason=str(longform_1000_human_review_closeout.get("reason") or "longform_1000_human_review_closeout_missing"), + source="longform_1000_human_review_closeout", + owner="ops_review", + severity="info", + next_action=( + "capture_longform_1000_human_reviews" + if str(longform_1000_human_review_closeout.get("status") or "watch") == "watch" + else "review_longform_1000_human_closeout" + ), + evidence={ + "status": longform_1000_human_review_closeout.get("status"), + "ready": bool(longform_1000_human_review_closeout.get("ready", False)), + "blocking_worlds": list(longform_1000_human_review_closeout.get("blocking_worlds", [])), + "watch_worlds": list(longform_1000_human_review_closeout.get("watch_worlds", [])), + "human_closeout_status": review_sample_coverage_1000.get("human_closeout_status"), + "human_closeout_ready": bool(review_sample_coverage_1000.get("human_closeout_ready", False)), + "human_reviewed_target_count": int(review_sample_coverage_1000.get("human_reviewed_target_count", 0) or 0), + "planned_target_count": int(review_sample_coverage_1000.get("planned_target_count", 0) or 0), + }, + ) + ] + if longform_1000_human_review_closeout + else [] + ) + ( + [ + self._checklist_item( + key="q06_character_fidelity_framework", + label="Q06 character fidelity framework", + ok=True, + review_status="watch" if character_fidelity_remediation_framework.get("available") else "ready", + reason=( + "q06_character_fidelity_framework_active" + if character_fidelity_remediation_framework.get("available") + else "q06_character_fidelity_framework_clear" + ), + source="character_fidelity_remediation_framework", + owner="planner", + severity="info", + next_action=( + "tighten_character_cards_and_emotion_action_policies" + if character_fidelity_remediation_framework.get("available") + else "none" + ), + evidence=character_fidelity_remediation_framework, + ) + ] + if cross_pack_summary + else [] + ) + ( + [ + self._checklist_item( + key="reader_storybook_title_homogenization_trend", + label="Reader storybook title homogenization trend", + ok=True, + review_status="watch", + reason="reader_storybook_title_homogenization_promoted:%s" % ( + "/".join( + f"{item.get('jade_world_id')}@{int(item.get('consecutive_warning_count', 0) or 0)}" + for item in reader_storybook_title_homogenization_promoted_pairs + ) + or "watch" + ), + source="reader_storybook_long_route_smoke", + owner="ops_release", + severity="info", + next_action="inspect_release_evidence_bundle", + evidence={ + "threshold": int( + reader_storybook_title_homogenization_history_summary.get("threshold", 0) + or 0 + ), + "entry_count": int( + reader_storybook_title_homogenization_history_summary.get("entry_count", 0) + or 0 + ), + "latest_generated_at": reader_storybook_title_homogenization_history_summary.get( + "latest_generated_at" + ), + "trend_status": reader_storybook_title_homogenization_history_summary.get( + "trend_status" + ), + "trend_reason": reader_storybook_title_homogenization_history_summary.get( + "trend_reason" + ), + "promoted_pairs": reader_storybook_title_homogenization_promoted_pairs, + }, + ) + ] + if reader_storybook_title_homogenization_promoted_pairs + else [] + ) + ( + [ + self._checklist_item( + key="longform_500_human_review_closeout", + label="Longform 500 human-review closeout", + ok=True, + review_status=str(longform_500_human_review_closeout.get("status") or "watch"), + reason=str(longform_500_human_review_closeout.get("reason") or "longform_500_human_review_closeout_missing"), + source="longform_500_human_review_closeout", + owner="ops_review", + severity="info", + next_action=( + "capture_longform_500_human_reviews" + if str(longform_500_human_review_closeout.get("status") or "watch") == "watch" + else "review_longform_500_human_closeout" + ), + evidence={ + "status": longform_500_human_review_closeout.get("status"), + "ready": bool(longform_500_human_review_closeout.get("ready", False)), + "blocking_worlds": list(longform_500_human_review_closeout.get("blocking_worlds", [])), + "watch_worlds": list(longform_500_human_review_closeout.get("watch_worlds", [])), + "human_closeout_status": review_sample_coverage_500.get("human_closeout_status"), + "human_closeout_ready": bool(review_sample_coverage_500.get("human_closeout_ready", False)), + "human_reviewed_target_count": int(review_sample_coverage_500.get("human_reviewed_target_count", 0) or 0), + "planned_target_count": int(review_sample_coverage_500.get("planned_target_count", 0) or 0), + }, + ) + ] + if longform_500_human_review_closeout + else [] + ) + ( + [ + self._checklist_item( + key="longform_500_ending_signoff", + label="Longform 500 ending sign-off", + ok=True, + review_status=str(longform_500_ending_signoff.get("status") or "watch"), + reason=str(longform_500_ending_signoff.get("reason") or "longform_500_ending_signoff_missing"), + source="longform_500_ending_signoff", + owner="ops_review", + severity="info", + next_action=( + "capture_longform_500_ending_window_human_reviews" + if str(longform_500_ending_signoff.get("status") or "watch") == "watch" + else "review_longform_500_ending_evidence" + ), + evidence={ + "status": longform_500_ending_signoff.get("status"), + "ready": bool(longform_500_ending_signoff.get("ready", False)), + "blocking_worlds": list(longform_500_ending_signoff.get("blocking_worlds", [])), + "watch_worlds": list(longform_500_ending_signoff.get("watch_worlds", [])), + "ending_window_label": review_sample_coverage_500.get("ending_window_label"), + "ending_window_human_closeout_ready": bool(review_sample_coverage_500.get("ending_window_human_closeout_ready", False)), + "ending_window_human_reviewed_count": int(review_sample_coverage_500.get("ending_window_human_reviewed_count", 0) or 0), + "ending_window_target_count": int(review_sample_coverage_500.get("ending_window_target_count", 0) or 0), + }, + ) + ] + if longform_500_ending_signoff + else [] + ) + ( + [ + self._checklist_item( + key="longform_500_release_bundle", + label="Longform 500 release bundle", + ok=True, + review_status=str(dict(longform_500_release_bundle.get("combined_signoff", {})).get("status") or "watch"), + reason=str(dict(longform_500_release_bundle.get("combined_signoff", {})).get("reason") or "longform_500_release_bundle_missing"), + source="longform_500_release_bundle", + owner="ops_release", + severity="info", + next_action=( + "inspect_release_evidence_bundle" + if bool(longform_500_release_bundle.get("release_ready", False)) + else "close_longform_500_release_evidence_gaps" + ), + evidence={ + "release_ready": bool(longform_500_release_bundle.get("release_ready", False)), + "component_readiness": { + "static": bool(longform_500_signoff.get("ready", False)), + "interactive": bool(longform_500_interactive_signoff.get("ready", False)), + "human_review_closeout": bool(longform_500_human_review_closeout.get("ready", False)), + "ending_signoff": bool(longform_500_ending_signoff.get("ready", False)), + }, + "artifact_paths": { + "bundle_json": dict(longform_500_release_bundle.get("bundle_artifact", {})).get("json"), + "static_json": dict(longform_500_release_bundle.get("static_artifact", {})).get("json"), + "interactive_json": dict(longform_500_release_bundle.get("interactive_artifact", {})).get("json"), + "human_review_json": dict(longform_500_release_bundle.get("human_review_artifact", {})).get("json"), + }, + }, + ) + ] + if longform_500_release_bundle + else [] + ) + ( + [ + self._checklist_item( + key="longform_1000_release_bundle", + label="Longform 1000 release bundle", + ok=True, + review_status=str(dict(longform_1000_release_bundle.get("combined_signoff", {})).get("status") or "watch"), + reason=str(dict(longform_1000_release_bundle.get("combined_signoff", {})).get("reason") or "longform_1000_release_bundle_missing"), + source="longform_1000_release_bundle", + owner="ops_release", + severity="info", + next_action=( + "inspect_release_evidence_bundle" + if bool(longform_1000_release_bundle.get("release_ready", False)) + else "close_longform_1000_release_evidence_gaps" + ), + evidence={ + "release_ready": bool(longform_1000_release_bundle.get("release_ready", False)), + "component_readiness": { + "static": bool(longform_1000_readiness.get("ready", False)), + "interactive": bool(longform_1000_interactive_signoff.get("ready", False)), + "human_review_closeout": bool(longform_1000_human_review_closeout.get("ready", False)), + }, + "artifact_paths": { + "bundle_json": dict(longform_1000_release_bundle.get("bundle_artifact", {})).get("json"), + "static_json": dict(longform_1000_release_bundle.get("static_artifact", {})).get("json"), + "interactive_json": dict(longform_1000_release_bundle.get("interactive_artifact", {})).get("json"), + "human_review_json": dict(longform_1000_release_bundle.get("human_review_artifact", {})).get("json"), + }, + }, + ) + ] + if longform_1000_release_bundle + else [] + ) def _recent_entitlement_events(self, version_ids: List[str]) -> List[Dict[str, Any]]: if not version_ids: @@ -492,6 +1559,13 @@ def publish(self, world_version_id: str, *, reviewer_id: Optional[str] = None) - world_version = self.repository.get_world_version(world_version_id) simulation = dict(world_version.simulation_report_json or {}) errors = self._publish_gate_errors(simulation) + checklist = self.build_publish_checklist(world_version_id) + errors.extend( + str(item.get("reason") or "") + for item in checklist + if not item.get("ok") and str(item.get("reason") or "").strip() + ) + errors = list(dict.fromkeys(errors)) assisted_gate_receipt = evaluate_assisted_gate_decision( repository=self.repository, world_version_id=world_version_id, @@ -513,6 +1587,17 @@ def publish(self, world_version_id: str, *, reviewer_id: Optional[str] = None) - }, ) if errors: + try: + record_publish_preflight_quality_event( + self.repository, + world_id=world_version.world_id, + world_version_id=world_version_id, + status="blocked", + reason_codes=errors, + reviewer_id=reviewer_id, + ) + except Exception: + pass risk_summary = {"publish_gate_errors": errors, "publish_ready": False} self._record_lifecycle( asset_type="world_version", @@ -552,6 +1637,17 @@ def publish(self, world_version_id: str, *, reviewer_id: Optional[str] = None) - ) raise ValueError(errors[0]) + try: + record_publish_preflight_quality_event( + self.repository, + world_id=world_version.world_id, + world_version_id=world_version_id, + status="passed", + reason_codes=[], + reviewer_id=reviewer_id, + ) + except Exception: + pass previous_world_version_id = next( (item["world_version_id"] for item in self.repository.list_world_versions(world_id=world_version.world_id) if item["status"] == "published"), None, @@ -669,6 +1765,34 @@ def world_status(self, world_id: str) -> Dict[str, Any]: entitlement_events=entitlement_events, ) recent_reviews_drilldown = [self._review_timeline_entry(item) for item in recent_reviews] + cross_pack_summary = dict((latest_simulation.get("cross_pack_summary") or {})) + release_evidence_bundle = self._build_release_evidence_bundle(cross_pack_summary) + author_longform_capability = self._author_longform_capability(active_version) + ops_release_ready_band = self._ops_release_ready_band( + longform_signoff=dict(cross_pack_summary.get("longform_l1_signoff", {})), + interactive_longform_signoff=dict(cross_pack_summary.get("interactive_longform_signoff", {})), + longform_250_signoff=dict(cross_pack_summary.get("longform_250_signoff", {})), + longform_250_interactive_signoff=dict(cross_pack_summary.get("longform_250_interactive_signoff", {})), + longform_250_human_review_closeout=dict(cross_pack_summary.get("longform_250_human_review_closeout", {})), + longform_500_release_bundle=self._build_longform_500_release_evidence_bundle(cross_pack_summary), + longform_1000_release_bundle=self._build_longform_1000_release_evidence_bundle(cross_pack_summary) + if bool(cross_pack_summary.get("longform_1000_readiness") or cross_pack_summary.get("longform_1000_feasibility")) + else {}, + ) + author_claim_alignment = self._author_claim_alignment( + author_capability=author_longform_capability, + ops_release_ready_band=ops_release_ready_band, + ) + release_evidence_bundle = self._augment_release_evidence_bundle( + release_evidence_bundle=release_evidence_bundle, + author_capability=author_longform_capability, + author_claim_alignment=author_claim_alignment, + ) + release_evidence_bundle.update( + self._reader_storybook_title_homogenization_release_evidence( + world_id=world_id, + ) + ) return { "world_id": world_id, "versions": versions, @@ -684,6 +1808,18 @@ def world_status(self, world_id: str) -> Dict[str, Any]: "rollback_targets": rollback_targets, "recent_entitlement_events": entitlement_events, "risk_summary": risk_summary, + "release_evidence_bundle": release_evidence_bundle, + "author_longform_capability": author_longform_capability, + "author_claim_alignment": author_claim_alignment, + "longform_1000_readiness": dict(cross_pack_summary.get("longform_1000_readiness") or {}), + "longform_1000_interactive_signoff": dict(cross_pack_summary.get("longform_1000_interactive_signoff") or {}), + "longform_1000_human_review_closeout": dict(cross_pack_summary.get("longform_1000_human_review_closeout") or {}), + "longform_1000_feasibility": dict((latest_simulation.get("cross_pack_summary") or {}).get("longform_1000_feasibility") or {}), + "character_fidelity_remediation_framework": dict((latest_simulation.get("cross_pack_summary") or {}).get("character_fidelity_remediation_framework") or {}), + "quality_projection_summary": self.quality_projection.quality_summary( + world_version_id=active_version_id, + limit=12, + ), } diff --git a/src/narrativeos/services/runtime_ops.py b/src/narrativeos/services/runtime_ops.py index f92fee0..d474581 100644 --- a/src/narrativeos/services/runtime_ops.py +++ b/src/narrativeos/services/runtime_ops.py @@ -15,15 +15,71 @@ from sqlalchemy.engine.url import make_url from ..persistence.db import ( + AuditLogRow, AnalyticsEventRow, BillingCheckoutSessionRow, + BillingProfileRow, BillingLifecycleEventRow, BillingRetryAttemptRow, + BillableEventRow, + CampaignChannelTargetRow, + CampaignProofBundleRow, + CampaignReviewSubmissionRow, + CampaignRow, ChapterRow, + CreditBalanceRow, + CustomerAccountRow, + CustomerAuditExportRow, + CustomerSuccessSnapshotRow, + DataDeletionRequestRow, + DataRetentionPolicyRow, + DisputeRow, + DunningEventRow, + DunningRunRow, + ExpansionCandidateRow, + First30DayValueSummaryRow, + First7DayOutcomeRow, + FirstCustomerSuccessPackRow, + GoLiveDayCheckpointRow, + GoLiveDayRunRow, + GoLiveReadyAccountRow, + InvoiceIssuanceRow, + InvoicePreviewRow, + LaunchWaveStatusRow, + LaunchWeekGuardRunRow, + LibraryStatsCubeRow, + ManualAdjustmentRow, + OverageFlagRow, + PaymentRetryAttemptRow, + PaymentTransactionRow, + PilotToPaidReadinessScoreRow, + PilotConversionTrackRow, + PlanRow, + PartnerCapabilityRow, + PartnerHealthCheckRow, + PartnerRow, + ProductionCustomerAcceptanceRecordRow, + ProductionCutoverWindowRow, + ProductionLaunchEventRow, + ProductionPostmortemRecordRow, + ProductionPreflightCheckRow, + ProductionPreflightRunRow, + ProductionSignoffEvidenceRow, + ProductionSignoffItemRow, + ProductionSignoffRow, + ProviderWebhookEventRow, + CreditNoteRow, + RefundRequestRow, ReviewRecordRow, + RenewalTrackerRow, SessionRow, SubscriptionRow, + SupportCaseRow, + SettlementItemRow, + SettlementRunRow, + UsageLedgerRow, UsageMeterRow, + ChurnRiskFlagRow, ) from ..persistence.migrations import inspect_schema_lifecycle from ..persistence.repositories import SQLAlchemyPlatformRepository @@ -37,6 +93,62 @@ ("chapters", ChapterRow), ("review_records", ReviewRecordRow), ("subscriptions", SubscriptionRow), + ("plans", PlanRow), + ("customer_accounts", CustomerAccountRow), + ("billing_profiles", BillingProfileRow), + ("usage_ledgers", UsageLedgerRow), + ("billable_events", BillableEventRow), + ("invoice_previews", InvoicePreviewRow), + ("credit_balances", CreditBalanceRow), + ("overage_flags", OverageFlagRow), + ("campaigns", CampaignRow), + ("campaign_proof_bundles", CampaignProofBundleRow), + ("campaign_channel_targets", CampaignChannelTargetRow), + ("campaign_review_submissions", CampaignReviewSubmissionRow), + ("partners", PartnerRow), + ("partner_capabilities", PartnerCapabilityRow), + ("partner_health_checks", PartnerHealthCheckRow), + ("disputes", DisputeRow), + ("refund_requests", RefundRequestRow), + ("settlement_runs", SettlementRunRow), + ("settlement_items", SettlementItemRow), + ("support_cases", SupportCaseRow), + ("manual_adjustments", ManualAdjustmentRow), + ("audit_logs", AuditLogRow), + ("customer_audit_exports", CustomerAuditExportRow), + ("data_retention_policies", DataRetentionPolicyRow), + ("data_deletion_requests", DataDeletionRequestRow), + ("invoice_issuances", InvoiceIssuanceRow), + ("payment_transactions", PaymentTransactionRow), + ("provider_webhook_events", ProviderWebhookEventRow), + ("credit_notes", CreditNoteRow), + ("payment_retry_attempts", PaymentRetryAttemptRow), + ("dunning_events", DunningEventRow), + ("renewal_trackers", RenewalTrackerRow), + ("dunning_runs", DunningRunRow), + ("pilot_conversion_tracks", PilotConversionTrackRow), + ("expansion_candidates", ExpansionCandidateRow), + ("churn_risk_flags", ChurnRiskFlagRow), + ("production_signoffs", ProductionSignoffRow), + ("production_signoff_items", ProductionSignoffItemRow), + ("production_signoff_evidence", ProductionSignoffEvidenceRow), + ("production_cutover_windows", ProductionCutoverWindowRow), + ("production_customer_acceptance_records", ProductionCustomerAcceptanceRecordRow), + ("go_live_ready_accounts", GoLiveReadyAccountRow), + ("launch_wave_statuses", LaunchWaveStatusRow), + ("production_preflight_runs", ProductionPreflightRunRow), + ("production_preflight_checks", ProductionPreflightCheckRow), + ("first_7_day_outcomes", First7DayOutcomeRow), + ("first_30_day_value_summaries", First30DayValueSummaryRow), + ("pilot_to_paid_readiness_scores", PilotToPaidReadinessScoreRow), + ("customer_success_snapshots", CustomerSuccessSnapshotRow), + ("library_stats_cubes", LibraryStatsCubeRow), + ("production_launch_events", ProductionLaunchEventRow), + ("production_postmortem_records", ProductionPostmortemRecordRow), + ("go_live_day_runs", GoLiveDayRunRow), + ("go_live_day_checkpoints", GoLiveDayCheckpointRow), + ("launch_week_guard_runs", LaunchWeekGuardRunRow), + ("first_customer_success_packs", FirstCustomerSuccessPackRow), ("usage_meters", UsageMeterRow), ("analytics_events", AnalyticsEventRow), ("billing_checkout_sessions", BillingCheckoutSessionRow), @@ -1524,6 +1636,22 @@ def build_preflight_verification_bundle(self, *, account_id: Optional[str] = Non "GET /v1/ops/data-integrity", "GET /v1/ops/runtime-incident-snapshot", "POST /v1/ops/recovery-drill", + "bash scripts/run_commercial_long_route_50.sh " + "# emits artifacts/commercial_long_route_50.json and artifacts/commercial_long_route_50.md", + "cd Kimi_Agent_设计系统加载/app && npm run smoke:local " + "# emits artifacts/quantum_local_acceptance_smoke_result.json with fixed ports 3000/8000", + "cd Kimi_Agent_设计系统加载/app && npm run smoke:reader-paid " + "# emits artifacts/reader_paid_path_smoke_result.json with fixed ports 3000/8000", + "bash scripts/run_author_repair_loop_smoke.sh " + "# emits artifacts/author_repair_loop_smoke_result.json for brief -> draft -> asset edit -> simulate repair-loop evidence", + "bash scripts/run_stripe_sandbox_local.sh " + "# emits artifacts/stripe_external_acceptance/latest/external_acceptance_summary.json", + "python scripts/run_backup_restore_verification_hooks.py " + "# emits backup/restore verification hook evidence for production preflight", + "bash scripts/run_quantum_ops_url_state_smoke.sh " + "# emits artifacts/quantum_ops_url_state_smoke_result.json for Ops alerts/governance URL-state evidence", + "python scripts/build_operator_evidence_closure.py " + "# emits artifacts/human_signoff_closure/latest/operator_evidence_closure.json for production operator evidence closure", "bash scripts/run_cross_pack_merge_gate.sh", ] return { diff --git a/src/narrativeos/services/sessions.py b/src/narrativeos/services/sessions.py index d80fab9..057e25e 100644 --- a/src/narrativeos/services/sessions.py +++ b/src/narrativeos/services/sessions.py @@ -5,23 +5,90 @@ from ..core.linter import lint_chapter_draft from ..intent import SimpleIntentParser +from ..longform import ( + apply_steering_directive, + configure_interactive_longform_runtime, + configure_longform_runtime, + record_replan_debt, +) +from .authoring import _default_memory_compression_policy, _resolve_longform_structure from ..models import CandidateBatch, NarrativeState, StepRecord from ..pipeline import plan_next_turn from ..persistence.repositories import SQLAlchemyPlatformRepository from ..providers import StaticCandidateProvider +from ..long_route_quality import apply_long_route_quality_controls +from ..quality.adapter import enforce_grounding_quality_gate, persist_guardrail_records +from ..quality.hard_constraints import enforce_generation_hard_constraints +from ..quality.grounding import build_grounding_check from ..rendering import TemplateRenderer -from ..eval.service import evaluate_chapter +from ..sanitizer import sanitize_reader_visible_payload +from ..eval.service import evaluate_persisted_chapter from .analytics import AnalyticsService from .billing import BillingService +from .choice_semantics import build_choice_impacts, merge_choice_impacts_into_reader_view +from .illustration import IllustrationService from .observability import ObservabilityService from .provider_routing import ProviderRoutingService class ReaderContinueCommand: - def __init__(self, session_id: str, choice_id: str | None = None, freeform_intent: str | None = None) -> None: + def __init__( + self, + session_id: str, + choice_id: str | None = None, + freeform_intent: str | None = None, + steering_directive: Optional[Dict[str, Any]] = None, + ) -> None: self.session_id = session_id self.choice_id = choice_id self.freeform_intent = freeform_intent + self.steering_directive = dict(steering_directive or {}) + + +def build_reader_continuity_contract( + *, + status: str, + session_id: str, + paywall: Optional[Dict[str, Any]] = None, + quality_gate: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + normalized_status = str(status or "ok") + message = "这一章已经推进完成,可以继续阅读。" + primary_action = "continue_reading" + retryable = False + chapter_context_retained = normalized_status in {"payment_required", "quality_guard_failed", "restricted"} + if normalized_status == "payment_required": + tier_name = str((paywall or {}).get("required_display_name") or (paywall or {}).get("suggested_checkout_tier") or "推荐档位") + message = f"继续前需要先解锁,当前 session 和阅读位置已保留;完成支付后会直接回到这一章。" + if tier_name: + message = f"{message} 推荐使用 {tier_name} 继续。" + primary_action = "unlock_and_resume" + elif normalized_status == "restricted": + message = "当前章节因限制策略被拦下,但阅读位置已保留;需要先处理限制原因后再继续。" + primary_action = "resolve_restriction" + elif normalized_status == "quality_guard_failed": + quality_payload = dict(quality_gate or {}) + issues = [str(item.get("issue_code") or "") for item in list(quality_payload.get("issues") or []) if str(item.get("issue_code") or "")] + required_units = int(quality_payload.get("required_text_units", 0) or 0) + actual_units = int(quality_payload.get("actual_text_units", 0) or 0) + message = "本章未入库,但当前 session、阅读位置和上一章内容都已保留;可直接重试当前章。" + if required_units or actual_units: + message += f" 当前文本 {actual_units}/{required_units or '-'}。" + if issues: + message += f" 主要问题:{' / '.join(issues[:3])}。" + primary_action = "retry_current_chapter" + retryable = True + return { + "status": normalized_status, + "resume_session_id": session_id, + "preserve_session_context": True, + "preserve_workspace": "read", + "preserve_view": "previous_reader_view", + "chapter_context_retained": chapter_context_retained, + "primary_action": primary_action, + "retryable": retryable, + "message": message, + } class SessionService: @@ -35,6 +102,7 @@ def __init__( analytics_service: Optional[AnalyticsService] = None, observability_service: Optional[ObservabilityService] = None, provider_routing_service: Optional[ProviderRoutingService] = None, + illustration_service: Optional[IllustrationService] = None, ) -> None: self.repository = repository self.intent_parser = intent_parser or SimpleIntentParser() @@ -43,6 +111,7 @@ def __init__( self.analytics = analytics_service or AnalyticsService(repository) self.observability = observability_service or ObservabilityService(repository) self.provider_routing = provider_routing_service + self.illustration = illustration_service def _candidate_reranker(self, *, world_id: str, world_version_id: str): def _rerank(**context: Any) -> Dict[str, Any]: @@ -72,25 +141,177 @@ def _entitlement_snapshot(self, access: Dict[str, Any]) -> Dict[str, Any]: "status": access.get("status"), } - def create_session(self, world_id: str, reader_id: str | None = None) -> dict[str, Any]: + def _prepare_longform_state( + self, + *, + runtime: Any, + state: NarrativeState, + longform_setup: Optional[Dict[str, Any]] = None, + ) -> NarrativeState: + worldpack = runtime.worldpack + longform_structure = None + if worldpack.series_plan and worldpack.volume_plans and worldpack.arc_plans: + longform_structure = { + "series_plan": worldpack.series_plan.to_dict(), + "volume_plans": [item.to_dict() for item in worldpack.volume_plans], + "arc_plans": [item.to_dict() for item in worldpack.arc_plans], + "chapter_budget_policy": worldpack.chapter_budget_policy.to_dict() if worldpack.chapter_budget_policy else {}, + } + elif getattr(worldpack, "scene_blueprints", None): + longform_structure = _resolve_longform_structure( + worldpack_payload=worldpack.to_dict(), + runtime_world_title=runtime.world_record.world.title, + max_chapters=100, + ) + if longform_structure: + configure_longform_runtime( + state, + series_plan=dict(longform_structure.get("series_plan") or {}), + volume_plans=[dict(item) for item in longform_structure.get("volume_plans", [])], + arc_plans=[dict(item) for item in longform_structure.get("arc_plans", [])], + chapter_budget_policy=dict(longform_structure.get("chapter_budget_policy") or {}), + memory_compression_policy=dict( + getattr(worldpack, "memory_compression_policy", {}) + or _default_memory_compression_policy( + len(list(longform_structure.get("volume_plans") or [])) + ) + ), + world=runtime.world_record.world, + ) + setup = dict(longform_setup or {}) + configure_interactive_longform_runtime( + state, + series_storyline_contract={ + **dict(getattr(worldpack, "series_storyline_contract", {}) or {}), + **dict(setup.get("series_storyline_contract") or {}), + }, + character_memory_profiles={ + **dict(getattr(worldpack, "character_memory_profiles", {}) or {}), + **dict(setup.get("character_memory_profiles") or {}), + }, + steering_guardrails={ + **dict(getattr(worldpack, "steering_guardrails", {}) or {}), + **dict(setup.get("steering_guardrails") or {}), + }, + ) + return state + + def _runtime_min_target_words(self, runtime: Any) -> Optional[int]: + chapter_budget_policy = getattr(runtime.worldpack, "chapter_budget_policy", None) + if chapter_budget_policy is None: + return None + if hasattr(chapter_budget_policy, "to_dict"): + payload = dict(chapter_budget_policy.to_dict() or {}) + else: + payload = dict(chapter_budget_policy or {}) + if payload.get("min_target_words") is None: + return None + return int(payload.get("min_target_words") or 0) + + def _effective_min_target_words( + self, + runtime: Any, + *, + chapter_index: int = 0, + story_phase: str = "", + ) -> Optional[int]: + base = self._runtime_min_target_words(runtime) + if base is None: + return None + normalized_phase = str(story_phase or "").strip() + normalized_chapter_index = int(chapter_index or 0) + if normalized_phase == "setup" or normalized_chapter_index <= 1: + return min(base, 900) + if normalized_phase == "early_rising": + return min(base, 1200) + return base + + def _effective_target_words( + self, + target_words: Any, + *, + chapter_index: int = 0, + story_phase: str = "", + ) -> Optional[int]: + try: + base = int(target_words or 0) + except (TypeError, ValueError): + return None + if base <= 0: + return None + normalized_phase = str(story_phase or "").strip() + normalized_chapter_index = int(chapter_index or 0) + if normalized_phase == "setup" or normalized_chapter_index <= 1: + return min(base, 1100) + if normalized_phase == "early_rising": + return min(base, 1400) + return base + + def _latest_non_pass_quality_trace_id(self, session_id: str) -> Optional[str]: + for item in self.repository.list_quality_events(session_id=session_id, limit=20): + if str(item.get("status") or "") in {"blocked", "review_required"}: + trace_id = str(item.get("trace_id") or "").strip() + if trace_id: + return trace_id + return None + + def _record_quality_feedback_item( + self, + *, + feedback_type: str, + signal: str, + source_surface: str, + account_id: Optional[str], + world_version_id: Optional[str], + session_id: Optional[str], + chapter_id: Optional[str] = None, + trace_id: Optional[str] = None, + source_event_id: Optional[str] = None, + source_ref: Optional[Dict[str, Any]] = None, + payload: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + return self.repository.save_quality_feedback_item( + { + "feedback_type": feedback_type, + "signal": signal, + "source_surface": source_surface, + "account_id": account_id, + "world_version_id": world_version_id, + "session_id": session_id, + "chapter_id": chapter_id, + "trace_id": trace_id, + "source_event_id": source_event_id, + "source_ref": dict(source_ref or {}), + "payload": dict(payload or {}), + } + ) + + def create_session(self, world_id: str, reader_id: str | None = None, longform_setup: Optional[Dict[str, Any]] = None) -> dict[str, Any]: world = next((item for item in self.repository.list_worlds() if item["world_id"] == world_id), None) if world is None: raise KeyError("unknown_world:%s" % world_id) runtime = self.repository.get_runtime_bundle(world["latest_version"]) + account_id = self.billing.resolve_account_id(reader_id=reader_id) + initial_state = self._prepare_longform_state( + runtime=runtime, + state=NarrativeState.from_dict(runtime.initial_state.to_dict()), + longform_setup=longform_setup, + ) session = self.repository.create_session_record( world_version_id=runtime.world_version_id, - initial_state=runtime.initial_state, + initial_state=initial_state, reader_id=reader_id, metadata={ "source": "beta_reader", - "account_id": self.billing.resolve_account_id(reader_id=reader_id), + "account_id": account_id, + "longform_setup_present": bool(longform_setup), }, entitlements_snapshot={"access_tier": "trial"}, ) access = self.billing.access_check( session.session_id, reader_id=reader_id, - account_id=self.billing.resolve_account_id(reader_id=reader_id), + account_id=account_id, ) snapshot = self._entitlement_snapshot(access) self.repository.update_session_entitlements_snapshot(session.session_id, snapshot) @@ -103,14 +324,22 @@ def create_session(self, world_id: str, reader_id: str | None = None) -> dict[st access_tier=access.get("access_tier"), payload_json=snapshot, ) + if self.illustration is not None: + self.illustration.ensure_session_cover( + session_id=session.session_id, + reader_id=reader_id, + world_version_id=runtime.world_version_id, + ) + self.illustration.ensure_world_cover(world_version_id=runtime.world_version_id) return { "session_id": session.session_id, "reader_id": reader_id, - "account_id": self.billing.resolve_account_id(reader_id=reader_id), + "account_id": account_id, "world_id": world_id, "world_version_id": runtime.world_version_id, "current_state": session.current_state.to_dict(), "paywall": access, + "steering_checkpoint": dict(initial_state.storyline_checkpoint or {}), } def continue_story(self, command: ReaderContinueCommand, *, reader_id: str | None = None) -> dict[str, Any]: @@ -122,6 +351,7 @@ def continue_story(self, command: ReaderContinueCommand, *, reader_id: str | Non ) world_version_id = str(session_record.metadata.get("world_version_id")) runtime = self.repository.get_runtime_bundle(world_version_id) + prior_quality_trace_id = self._latest_non_pass_quality_trace_id(command.session_id) access = self.billing.access_check(command.session_id, reader_id=reader_id, account_id=account_id) if access["required"]: latest_step = self.repository.get_latest_step(command.session_id) @@ -149,18 +379,52 @@ def continue_story(self, command: ReaderContinueCommand, *, reader_id: str | Non reader_id=reader_id, estimated_cost=0.0, ) + try: + self._record_quality_feedback_item( + feedback_type=blocked_status, + signal="negative_proxy", + source_surface="reader", + account_id=account_id, + world_version_id=runtime.world_version_id, + session_id=command.session_id, + trace_id=prior_quality_trace_id, + source_ref={"kind": "session", "session_id": command.session_id, "account_id": account_id}, + payload={"blocked_status": blocked_status, "access_reason": access.get("reason")}, + ) + except Exception: + pass return { "session_id": command.session_id, "world_id": runtime.worldpack.world_id, "world_version_id": runtime.world_version_id, "chapter_view": latest_step.reader_view.to_dict() if latest_step and latest_step.reader_view else None, + "reader_view": latest_step.reader_view.to_dict() if latest_step and latest_step.reader_view else None, + "updated_state_summary": None, + "replay_preview": None, "paywall": access, "status": blocked_status, + "continuity_contract": build_reader_continuity_contract( + status=blocked_status, + session_id=command.session_id, + paywall=access, + ), } + source_chapter_index = int(session_record.current_state.chapter_index or 0) state_before = NarrativeState.from_dict(session_record.current_state.to_dict()) + self._prepare_longform_state(runtime=runtime, state=state_before) player_input = command.freeform_intent or command.choice_id or "继续读下去。" state_before.player_intent = self.intent_parser.parse(player_input) + steering_checkpoint: Dict[str, Any] = {} + if command.steering_directive: + steering_directive = dict(command.steering_directive or {}) + steering_directive.setdefault("current_user_intent", player_input) + steering_result = apply_steering_directive( + state_before, + steering_directive, + world=runtime.world_record.world, + ) + steering_checkpoint = dict(steering_result.get("replan_checkpoint") or {}) candidate_provider = ( self.provider_routing.build_candidate_provider( runtime.event_atoms, @@ -197,6 +461,16 @@ def continue_story(self, command: ReaderContinueCommand, *, reader_id: str | Non debug=True, ) runtime_latency_ms = round((perf_counter() - started) * 1000.0, 3) + if result.get("status") == "ok" and isinstance(result.get("reader_view"), dict): + sanitized_reader_view, reader_visible_language_debug = sanitize_reader_visible_payload(dict(result["reader_view"] or {})) + result["reader_view"] = sanitized_reader_view + if isinstance(result.get("rendered_scene"), dict): + rendered_debug = dict((result["rendered_scene"].get("debug") or {})) + rendered_debug["reader_visible_language_debug"] = reader_visible_language_debug + result["rendered_scene"] = { + **dict(result["rendered_scene"] or {}), + "debug": rendered_debug, + } if result["status"] != "ok": self.observability.record_runtime_receipt( surface="reader", @@ -213,15 +487,244 @@ def continue_story(self, command: ReaderContinueCommand, *, reader_id: str | Non estimated_cost=0.0, runtime_latency_ms=runtime_latency_ms, ) + if prior_quality_trace_id: + try: + self._record_quality_feedback_item( + feedback_type="retry_after_quality_guard", + signal="retry", + source_surface="reader", + account_id=account_id, + world_version_id=runtime.world_version_id, + session_id=command.session_id, + trace_id=prior_quality_trace_id, + source_ref={"kind": "session", "session_id": command.session_id, "account_id": account_id}, + payload={"result_status": str(result["status"]), "retry_trigger": "continue_story"}, + ) + except Exception: + pass return { "session_id": command.session_id, "world_id": runtime.worldpack.world_id, "world_version_id": runtime.world_version_id, "status": result["status"], "paywall": access, + "continuity_contract": build_reader_continuity_contract( + status=str(result["status"]), + session_id=command.session_id, + paywall=access, + ), } updated_state = NarrativeState.from_dict(result["updated_state"]) + chapter_task = dict((result.get("chapter_plan") or {}).get("chapter_task") or {}) + 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": chapter_task, + } + repaired_reader_view, updated_state, long_route_quality_debug = apply_long_route_quality_controls( + dict(result.get("reader_view") or {}), + state_before=state_before, + state_after=updated_state, + coverage_context=coverage_context, + ) + repaired_reader_view = merge_choice_impacts_into_reader_view( + repaired_reader_view, + routes=result.get("routes") or [], + chapter_index=int(updated_state.chapter_index or 0), + ) + result["reader_view"] = repaired_reader_view + result["updated_state"] = updated_state.to_dict() + if isinstance(result.get("rendered_scene"), dict): + rendered_debug = dict((result["rendered_scene"].get("debug") or {})) + rendered_debug["long_route_quality_controls"] = long_route_quality_debug + result["rendered_scene"] = { + **dict(result["rendered_scene"] or {}), + "debug": rendered_debug, + } + body = str(result["reader_view"].get("body") or "") + lint_report = lint_chapter_draft(body) + target_chapters = int(getattr(getattr(runtime.worldpack, "series_plan", None), "total_chapter_target", 0) or 100) + quality_bundle = evaluate_persisted_chapter( + chapter_id="chapter_%s_%s" % (command.session_id, updated_state.chapter_index), + world_version_id=runtime.world_version_id, + session_id=command.session_id, + body=body, + paragraphs=body.split("\n\n"), + 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, + ), + state_after=updated_state, + ending_ready=bool((result.get("chapter_plan") or {}).get("ending_ready")), + chapter_title=result["reader_view"].get("chapter_title"), + recap=result["reader_view"].get("recap"), + relationship_hints=list(result["reader_view"].get("relationship_hints") or []), + choices=result["reader_view"]["choices"], + paywall_required=bool(access["required"]), + coverage_context=coverage_context, + target_words=self._effective_target_words( + chapter_task.get("target_words"), + chapter_index=int(updated_state.chapter_index or 0), + story_phase=str(updated_state.story_phase or ""), + ), + min_target_words=self._effective_min_target_words( + runtime, + chapter_index=int(updated_state.chapter_index or 0), + story_phase=str(updated_state.story_phase or ""), + ), + chapter_index=int(updated_state.chapter_index or 0), + target_chapters=target_chapters, + story_phase=str(updated_state.story_phase or ""), + rolling_quality_window=list((state_before.metadata or {}).get("quality_contract_window", [])), + enforcement_scope="reader_session_generation", + ) + grounding_check = build_grounding_check( + scenario_id="reader_continue", + text=body, + source_surface="reader", + world_version_id=runtime.world_version_id, + session_id=command.session_id, + chapter_id="chapter_%s_%s" % (command.session_id, updated_state.chapter_index), + coverage_context=coverage_context, + state_after=updated_state, + worldpack_payload=runtime.worldpack.to_dict() if hasattr(runtime.worldpack, "to_dict") else None, + ) + quality_bundle = enforce_grounding_quality_gate( + quality_bundle, + grounding_check=grounding_check, + source_surface="reader", + ) + worldpack_payload = runtime.worldpack.to_dict() if hasattr(runtime.worldpack, "to_dict") else None + quality_bundle = enforce_generation_hard_constraints( + quality_bundle, + reader_view=dict(result.get("reader_view") or {}), + grounding_check=grounding_check, + source_surface="reader", + target_chapters=target_chapters, + worldpack_payload=worldpack_payload, + repair_report=long_route_quality_debug, + ) + quality_records = {} + try: + quality_records = persist_guardrail_records( + self.repository, + quality_bundle=quality_bundle, + scenario_id="reader_continue", + source_surface="reader", + source_ref={ + "kind": "chapter", + "chapter_id": "chapter_%s_%s" % (command.session_id, updated_state.chapter_index), + "session_id": command.session_id, + "rendered_text": body, + }, + world_version_id=runtime.world_version_id, + session_id=command.session_id, + chapter_id="chapter_%s_%s" % (command.session_id, updated_state.chapter_index), + coverage_context=coverage_context, + state_after=updated_state, + worldpack_payload=worldpack_payload, + ) + except Exception: + quality_records = {} + record_replan_debt( + updated_state, + chapter_index=int(updated_state.chapter_index or 0), + issue_codes=[issue.issue_code for issue in quality_bundle["report"].issues], + ) + if not quality_bundle["quality_gate"]["ok"]: + self.analytics.track( + "chapter_quality_guard_failed", + reader_id=reader_id, + account_id=account_id, + session_id=command.session_id, + world_id=runtime.worldpack.world_id, + world_version_id=runtime.world_version_id, + chapter_index=updated_state.chapter_index, + access_tier=access.get("access_tier"), + payload_json={ + "surface": "reader_session_generation", + "quality_gate": quality_bundle["quality_gate"], + }, + ) + self.observability.record_runtime_receipt( + surface="reader", + action="continue_story", + response_status="quality_guard_failed", + world_id=runtime.worldpack.world_id, + world_version_id=runtime.world_version_id, + session_id=command.session_id, + account_id=account_id, + reader_id=reader_id, + candidate_batch=result.get("candidate_batch"), + rendered_scene=result.get("rendered_scene"), + reader_view=None, + estimated_cost=0.0, + runtime_latency_ms=runtime_latency_ms, + trace_id=quality_records.get("trace_id"), + quality_event_id=(quality_records.get("event") or {}).get("event_id"), + ) + try: + self._record_quality_feedback_item( + feedback_type="quality_guard_failed", + signal="negative_proxy", + source_surface="reader", + account_id=account_id, + world_version_id=runtime.world_version_id, + session_id=command.session_id, + chapter_id="chapter_%s_%s" % (command.session_id, updated_state.chapter_index), + trace_id=quality_records.get("trace_id"), + source_ref={ + "kind": "chapter", + "chapter_id": "chapter_%s_%s" % (command.session_id, updated_state.chapter_index), + "session_id": command.session_id, + "account_id": account_id, + }, + payload={ + "result_status": "quality_guard_failed", + "enforced_decision": quality_bundle["quality_gate"].get("enforced_decision"), + }, + ) + if prior_quality_trace_id: + self._record_quality_feedback_item( + feedback_type="retry_after_quality_guard", + signal="retry", + source_surface="reader", + account_id=account_id, + world_version_id=runtime.world_version_id, + session_id=command.session_id, + trace_id=prior_quality_trace_id, + source_ref={"kind": "session", "session_id": command.session_id, "account_id": account_id}, + payload={"result_status": "quality_guard_failed", "retry_trigger": "continue_story"}, + ) + except Exception: + pass + return { + "session_id": command.session_id, + "world_id": runtime.worldpack.world_id, + "world_version_id": runtime.world_version_id, + "status": "quality_guard_failed", + "code": quality_bundle["quality_gate"]["code"], + "quality_gate": quality_bundle["quality_gate"], + "paywall": access, + "reader_view": None, + "updated_state_summary": None, + "replay_preview": result.get("replay_preview"), + "quality_trace_id": quality_records.get("trace_id"), + "continuity_contract": build_reader_continuity_contract( + status="quality_guard_failed", + session_id=command.session_id, + paywall=access, + quality_gate=quality_bundle["quality_gate"], + ), + } + updated_state.metadata = { + **dict(updated_state.metadata or {}), + "quality_contract_window": list(quality_bundle["quality_gate"].get("quality_contract_window") or []), + } step_record = StepRecord.from_dict( { "session_id": command.session_id, @@ -244,6 +747,11 @@ def continue_story(self, command: ReaderContinueCommand, *, reader_id: str | Non "metadata": { "access_tier": access["access_tier"], "assisted_rerank_receipts": list(result.get("assisted_rerank_receipts") or []), + "steering_directive": dict(command.steering_directive or {}), + "steering_checkpoint": steering_checkpoint, + "reader_visible_language_debug": dict( + ((result.get("rendered_scene") or {}).get("debug") or {}).get("reader_visible_language_debug") or {} + ), }, } ) @@ -261,27 +769,25 @@ def continue_story(self, command: ReaderContinueCommand, *, reader_id: str | Non entitlements_snapshot=snapshot, cost_estimate=round(max(1, len(result["reader_view"]["body"])) / 1200.0, 3), ) + if (command.choice_id or command.freeform_intent or command.steering_directive) and source_chapter_index > 0: + source_chapter_id = "chapter_%s_%s" % (command.session_id, source_chapter_index) + try: + self.repository.save_route_choice( + session_id=command.session_id, + chapter_id=source_chapter_id, + choice_id=command.choice_id or "director_intent", + payload_json={ + "choice_id": command.choice_id, + "freeform_intent": command.freeform_intent, + "steering_directive": dict(command.steering_directive or {}), + "source_chapter_index": source_chapter_index, + "generated_chapter_index": int(updated_state.chapter_index or 0), + }, + ) + except Exception: + pass self.repository.update_session_entitlements_snapshot(command.session_id, snapshot) - lint_report = lint_chapter_draft(result["reader_view"]["body"]) - evaluation_report = evaluate_chapter( - chapter_id=chapter_id, - world_version_id=runtime.world_version_id, - session_id=command.session_id, - body=result["reader_view"]["body"], - paragraphs=result["reader_view"]["body"].split("\n\n"), - 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, - ), - state_after=updated_state, - ending_ready=bool(result["chapter_plan"]["ending_ready"]) if result.get("chapter_plan") else False, - choices=result["reader_view"]["choices"], - paywall_required=bool(access["required"]), - ) - self.repository.save_evaluation_report(chapter_id, evaluation_report) + self.repository.save_evaluation_report(chapter_id, quality_bundle["report"]) self.billing.meter_action( surface="reader", action_name="continue_story", @@ -342,8 +848,8 @@ def continue_story(self, command: ReaderContinueCommand, *, reader_id: str | Non world_id=runtime.worldpack.world_id, world_version_id=runtime.world_version_id, chapter_id=chapter_id, - decision=evaluation_report.decision.decision, - overall_score=evaluation_report.scores.overall_score, + decision=quality_bundle["report"].decision.decision, + overall_score=quality_bundle["report"].scores.overall_score, access_tier=consumed_access.get("access_tier"), ) for receipt in result.get("assisted_rerank_receipts") or []: @@ -370,6 +876,14 @@ def continue_story(self, command: ReaderContinueCommand, *, reader_id: str | Non access_tier=consumed_access.get("access_tier"), payload_json=receipt, ) + if self.illustration is not None: + self.illustration.ensure_chapter_hero( + session_id=command.session_id, + reader_id=reader_id, + world_version_id=runtime.world_version_id, + chapter_index=int(updated_state.chapter_index or 0), + rendered_scene=dict(result.get("rendered_scene") or {}), + ) runtime_cost = round(max(1, len(result["reader_view"]["body"])) / 1200.0, 3) self.observability.record_runtime_receipt( surface="reader", @@ -385,17 +899,43 @@ def continue_story(self, command: ReaderContinueCommand, *, reader_id: str | Non reader_view=result.get("reader_view"), estimated_cost=runtime_cost, runtime_latency_ms=runtime_latency_ms, + trace_id=quality_records.get("trace_id"), + quality_event_id=(quality_records.get("event") or {}).get("event_id"), ) + if prior_quality_trace_id: + try: + self._record_quality_feedback_item( + feedback_type="retry_after_quality_guard", + signal="retry", + source_surface="reader", + account_id=account_id, + world_version_id=runtime.world_version_id, + session_id=command.session_id, + trace_id=prior_quality_trace_id, + source_ref={"kind": "session", "session_id": command.session_id, "account_id": account_id}, + payload={"result_status": "ok", "retry_trigger": "continue_story"}, + ) + except Exception: + pass + choice_impacts = build_choice_impacts( + result["reader_view"].get("choices") or [], + reader_view=result["reader_view"], + routes=result.get("routes") or [], + chapter_index=int(updated_state.chapter_index or 0), + ) + impact_by_id = {str(item.get("choice_id")): item for item in choice_impacts} chapter_view = { "sessionId": command.session_id, "worldId": runtime.worldpack.world_id, "worldVersionId": runtime.world_version_id, + "quality_trace_id": quality_records.get("trace_id"), "chapterId": chapter_id, "chapterIndex": updated_state.chapter_index, "chapterTitle": result["reader_view"]["chapter_title"], "recap": result["reader_view"]["recap"], "body": result["reader_view"]["body"], "relationshipHints": result["reader_view"]["relationship_hints"], + "choice_impacts": choice_impacts, "choices": [ { "choiceId": "choice_%s_%s" % (updated_state.chapter_index, index), @@ -404,6 +944,7 @@ def continue_story(self, command: ReaderContinueCommand, *, reader_id: str | Non "emotionalCost": "命运继续收紧", "accessTier": "free" if consumed_access["access_tier"] == "free" else ("subscriber" if consumed_access["access_tier"] == "subscriber" else "paid"), "priceHint": 0 if consumed_access["access_tier"] in {"free", "subscriber"} else consumed_access["quote"], + "impact": impact_by_id.get("choice_%s_%s" % (updated_state.chapter_index, index), {}), } for index, choice_text in enumerate(result["reader_view"]["choices"], start=1) ], @@ -423,9 +964,13 @@ def continue_story(self, command: ReaderContinueCommand, *, reader_id: str | Non "reader_id": reader_id, "world_id": runtime.worldpack.world_id, "world_version_id": runtime.world_version_id, + "quality_trace_id": quality_records.get("trace_id"), "chapter_view": chapter_view, "reader_view": result["reader_view"], + "chosen_event": result.get("chosen_event"), + "updated_state": updated_state.to_dict(), "updated_state_summary": result["updated_state_summary"], + "replay_preview": result.get("replay_preview"), "paywall": { "required": False, "access_tier": consumed_access["access_tier"], @@ -435,5 +980,23 @@ def continue_story(self, command: ReaderContinueCommand, *, reader_id: str | Non "entitlement_type": consumed_access.get("entitlement_type"), "status": consumed_access.get("status"), }, + "candidate_batch": result.get("candidate_batch"), + "scored_candidates": result.get("scored_candidates"), + "critic_trace": result.get("critic_trace"), + "rendered_scene": result.get("rendered_scene"), + "routes": result.get("routes"), + "chapter_plan": result.get("chapter_plan"), + "scene_beats": result.get("scene_beats"), + "scene_render_spec": result.get("scene_render_spec"), "status": "ok", + "steering_checkpoint": steering_checkpoint or dict(updated_state.storyline_checkpoint or {}), + "replan_checkpoint": dict(updated_state.replan_checkpoint or {}), + "continuity_contract": build_reader_continuity_contract( + status="ok", + session_id=command.session_id, + paywall={ + "required": False, + "access_tier": consumed_access.get("access_tier"), + }, + ), } diff --git a/src/narrativeos/services/showcase_soul_surface_contract.py b/src/narrativeos/services/showcase_soul_surface_contract.py new file mode 100644 index 0000000..1d54043 --- /dev/null +++ b/src/narrativeos/services/showcase_soul_surface_contract.py @@ -0,0 +1,9 @@ +from __future__ import annotations + + +SHOWCASE_SOUL_TRACKED_EVENTS = { + "showcase_work_liked", + "showcase_work_unliked", + "showcase_work_commented", + "showcase_tip_sent", +} diff --git a/src/narrativeos/services/soul_profile_aggregate_contract.py b/src/narrativeos/services/soul_profile_aggregate_contract.py new file mode 100644 index 0000000..1f6b1f3 --- /dev/null +++ b/src/narrativeos/services/soul_profile_aggregate_contract.py @@ -0,0 +1,22 @@ +from __future__ import annotations + + +SOUL_PROFILE_AGGREGATE_TRACKED_EVENTS = { + "session_created", + "continue_story", + "story_share_created", + "story_share_revoked", + "library_work_favorited", + "library_work_unfavorited", + "library_target_followed", + "library_target_unfollowed", + "story_bookmark_created", + "story_bookmark_deleted", + "author_draft_created_from_brief", + "author_draft_saved", + "author_draft_updated", + "author_draft_validated", + "author_draft_simulated", + "author_draft_submitted", + "author_longform_workbench_bootstrapped", +} diff --git a/src/narrativeos/services/stripe_invoicing.py b/src/narrativeos/services/stripe_invoicing.py new file mode 100644 index 0000000..4bb60e8 --- /dev/null +++ b/src/narrativeos/services/stripe_invoicing.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +from decimal import Decimal +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from .billing import BillingService +from .commercial_audit import CommercialAuditService +from .customer_accounts import CustomerAccountService +from .monetization import MonetizationService +from ..persistence.repositories import SQLAlchemyPlatformRepository + + +class StripeInvoicingService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + monetization_service: MonetizationService, + billing_service: BillingService, + customer_account_service: CustomerAccountService, + audit_service: CommercialAuditService, + ) -> None: + self.repository = repository + self.monetization = monetization_service + self.billing = billing_service + self.customer_accounts = customer_account_service + self.audit = audit_service + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _json_safe(self, value: Any) -> Any: + if isinstance(value, Decimal): + return float(value) + if isinstance(value, dict): + return {str(key): self._json_safe(val) for key, val in value.items()} + if isinstance(value, (list, tuple)): + return [self._json_safe(item) for item in value] + return value + + def _billing_profile(self, *, customer_account_id: str) -> Optional[Dict[str, Any]]: + profiles = self.repository.list_billing_profiles(customer_account_id=customer_account_id, limit=1) + return profiles[0] if profiles else None + + def _invoice_status(self, stripe_status: Optional[str], *, fallback: str = "issued") -> str: + status = str(stripe_status or "").strip().lower() + return { + "draft": "draft", + "open": "issued", + "paid": "paid", + "void": "void", + "uncollectible": "failed", + }.get(status, fallback) + + def _line_items(self, invoice_preview: Dict[str, Any]) -> List[Dict[str, Any]]: + items = [] + for item in list(invoice_preview.get("line_items_json") or []): + amount = float(item.get("line_amount_usd") or 0.0) + if amount <= 0: + continue + items.append( + { + "metric_type": item.get("metric_type"), + "description": f"{item.get('display_name') or item.get('metric_type')} ({item.get('billable_units') or 0} billable units)", + "amount_usd": amount, + } + ) + return items + + def issue_invoice(self, *, invoice_preview_id: str, requested_by: str) -> Dict[str, Any]: + preview = self.repository.get_invoice_preview(invoice_preview_id) + customer = self.customer_accounts.customer_account_detail(account_id=preview["account_id"])["customer_account"] + profile = self._billing_profile(customer_account_id=customer["customer_account_id"]) + customer_email = (profile or {}).get("invoice_email") or (self.repository.get_auth_identity_profile(preview["account_id"], default={}) or {}).get("email_address") + customer_ref = (profile or {}).get("provider_customer_ref") + stripe_customer = self.monetization.ensure_stripe_customer( + customer_email=customer_email, + customer_id=customer_ref, + metadata={"account_id": preview["account_id"], "invoice_preview_id": invoice_preview_id}, + ) + if not customer_ref and profile is not None: + self.repository.save_billing_profile( + { + **profile, + "provider_customer_ref": stripe_customer.get("id"), + "profile_payload_json": dict(profile.get("profile_payload_json") or {}), + } + ) + line_items = self._line_items(preview) + issued = self.monetization.issue_stripe_invoice( + customer_id=stripe_customer.get("id"), + currency="USD", + line_items=line_items, + metadata={"account_id": preview["account_id"], "invoice_preview_id": invoice_preview_id}, + ) + invoice = self.repository.save_invoice_issuance( + { + "invoice_id": f"invoice_{invoice_preview_id}", + "invoice_preview_id": invoice_preview_id, + "customer_account_id": customer["customer_account_id"], + "account_id": preview["account_id"], + "provider": "stripe", + "provider_invoice_ref": issued.get("id"), + "provider_customer_ref": stripe_customer.get("id"), + "status": self._invoice_status(issued.get("status")), + "currency": "USD", + "subtotal_amount_usd": preview.get("subtotal_amount_usd") or 0.0, + "total_due_usd": preview.get("total_due_usd") or 0.0, + "hosted_invoice_url": issued.get("hosted_invoice_url"), + "invoice_pdf_url": issued.get("invoice_pdf"), + "issued_at": self._utcnow(), + "invoice_payload": issued, + } + ) + self.audit.record_audit_log( + actor_id=requested_by, + actor_role="reviewer", + account_id=preview["account_id"], + object_type="invoice", + object_id=invoice["invoice_id"], + action_type="invoice_issued", + source_surface="ops", + customer_visible_payload={"invoice": invoice}, + internal_payload={"invoice_preview": preview, "provider_payload": issued}, + ) + return { + "invoice": invoice, + "stripe_customer": stripe_customer, + } + + def list_invoices(self, *, account_id: Optional[str] = None, customer_account_id: Optional[str] = None, limit: int = 50) -> Dict[str, Any]: + invoices = self.repository.list_invoice_issuances(account_id=account_id, customer_account_id=customer_account_id, limit=limit) + transactions = self.repository.list_payment_transactions(account_id=account_id, limit=limit * 2 if account_id else limit * 2) + return { + "invoices": invoices, + "transactions": transactions, + "summary": { + "invoice_count": len(invoices), + "paid_count": sum(1 for item in invoices if str(item.get("status") or "") == "paid"), + "failed_count": sum(1 for item in invoices if str(item.get("status") or "") == "failed"), + "void_count": sum(1 for item in invoices if str(item.get("status") or "") == "void"), + "refunded_count": sum(1 for item in invoices if str(item.get("status") or "") == "refunded"), + }, + } + + def get_invoice_detail(self, *, invoice_id: str) -> Dict[str, Any]: + invoice = self.repository.get_invoice_issuance(invoice_id) + transactions = self.repository.list_payment_transactions(invoice_id=invoice_id, limit=50) + credit_notes = self.repository.list_credit_notes(invoice_id=invoice_id, limit=50) + retries = self.repository.list_payment_retry_attempts(invoice_id=invoice_id, limit=50) + dunning = self.repository.list_dunning_events(invoice_id=invoice_id, limit=50) + return { + "invoice": invoice, + "transactions": transactions, + "credit_notes": credit_notes, + "payment_retry_attempts": retries, + "dunning_events": dunning, + } + + def _record_payment_transaction( + self, + *, + invoice: Dict[str, Any], + provider_transaction_ref: Optional[str], + transaction_type: str, + status: str, + amount_usd: float, + payload: Dict[str, Any], + ) -> Dict[str, Any]: + return self.repository.save_payment_transaction( + { + "invoice_id": invoice["invoice_id"], + "customer_account_id": invoice.get("customer_account_id"), + "account_id": invoice["account_id"], + "provider": "stripe", + "provider_transaction_ref": provider_transaction_ref, + "transaction_type": transaction_type, + "status": status, + "amount_usd": amount_usd, + "currency": "USD", + "trace_id": payload.get("trace_id"), + "transaction_payload": self._json_safe(payload), + } + ) + + def _record_webhook(self, *, event: Dict[str, Any], invoice_id: Optional[str], account_id: Optional[str], processing_result: Dict[str, Any], status: str = "processed") -> Dict[str, Any]: + return self.repository.save_provider_webhook_event( + { + "provider": "stripe", + "provider_event_id": str(event.get("id") or ""), + "event_type": str(event.get("type") or ""), + "status": status, + "invoice_id": invoice_id, + "account_id": account_id, + "payload": self._json_safe(event), + "processing_result": self._json_safe(processing_result), + "processed_at": self._utcnow(), + } + ) + + def _invoice_from_provider_ref(self, provider_invoice_ref: str) -> Dict[str, Any]: + return self.repository.get_invoice_issuance_by_provider_ref(provider_invoice_ref) + + def ingest_stripe_webhook(self, *, raw_body: bytes, signature: str) -> Dict[str, Any]: + webhook_secret = self.billing._stripe_webhook_secret() + if not webhook_secret: + raise ValueError("stripe_webhook_not_configured") + try: + import stripe # type: ignore + except ModuleNotFoundError as exc: + raise ValueError("stripe_sdk_missing") from exc + stripe.api_key = self.monetization.stripe_checkout.secret_key + stripe.api_version = self.monetization.stripe_checkout.api_version + try: + stripe_event = stripe.Webhook.construct_event(payload=raw_body, sig_header=signature, secret=webhook_secret) + except Exception as exc: + raise ValueError("stripe_webhook_signature_invalid") from exc + event = stripe_event.to_dict() if hasattr(stripe_event, "to_dict") else dict(stripe_event) + event_type = str(event.get("type") or "") + data_object = dict(dict(event.get("data") or {}).get("object") or {}) + provider_invoice_ref = str(data_object.get("invoice") or data_object.get("id") or "").strip() + invoice = self._invoice_from_provider_ref(provider_invoice_ref) if provider_invoice_ref else None + processing_result: Dict[str, Any] = {} + if event_type == "invoice.paid" and invoice: + invoice = self.repository.save_invoice_issuance( + { + **invoice, + "status": "paid", + "invoice_payload_json": dict(invoice.get("invoice_payload_json") or {}), + "paid_at": self._utcnow(), + } + ) + processing_result["invoice"] = invoice + processing_result["payment_transaction"] = self._record_payment_transaction( + invoice=invoice, + provider_transaction_ref=str(data_object.get("payment_intent") or data_object.get("charge") or provider_invoice_ref), + transaction_type="payment", + status="paid", + amount_usd=float(invoice.get("total_due_usd") or 0.0), + payload=data_object, + ) + elif event_type == "invoice.payment_failed" and invoice: + invoice = self.repository.save_invoice_issuance( + { + **invoice, + "status": "failed", + "invoice_payload_json": dict(invoice.get("invoice_payload_json") or {}), + } + ) + processing_result["invoice"] = invoice + processing_result["payment_transaction"] = self._record_payment_transaction( + invoice=invoice, + provider_transaction_ref=str(data_object.get("payment_intent") or data_object.get("charge") or provider_invoice_ref), + transaction_type="payment", + status="failed", + amount_usd=float(invoice.get("total_due_usd") or 0.0), + payload=data_object, + ) + processing_result["payment_retry_attempt"] = self.repository.save_payment_retry_attempt( + { + "invoice_id": invoice["invoice_id"], + "customer_account_id": invoice.get("customer_account_id"), + "account_id": invoice["account_id"], + "provider": "stripe", + "status": "planned", + "retry_reason": "invoice_payment_failed", + "attempt_count": 1, + "next_retry_at": (datetime.now(timezone.utc) + timedelta(days=1)).isoformat(), + "retry_payload": self._json_safe(data_object), + } + ) + processing_result["dunning_event"] = self.repository.save_dunning_event( + { + "invoice_id": invoice["invoice_id"], + "customer_account_id": invoice.get("customer_account_id"), + "account_id": invoice["account_id"], + "status": "scheduled", + "step": "payment_failed_initial", + "event_payload": self._json_safe(data_object), + } + ) + elif event_type == "invoice.voided" and invoice: + invoice = self.repository.save_invoice_issuance( + { + **invoice, + "status": "void", + "invoice_payload_json": dict(invoice.get("invoice_payload_json") or {}), + "voided_at": self._utcnow(), + } + ) + processing_result["invoice"] = invoice + elif event_type in {"charge.refunded", "refund.updated"} and invoice: + invoice = self.repository.save_invoice_issuance( + { + **invoice, + "status": "refunded", + "invoice_payload_json": dict(invoice.get("invoice_payload_json") or {}), + } + ) + processing_result["invoice"] = invoice + processing_result["payment_transaction"] = self._record_payment_transaction( + invoice=invoice, + provider_transaction_ref=str(data_object.get("id") or provider_invoice_ref), + transaction_type="refund", + status="refunded", + amount_usd=float(invoice.get("total_due_usd") or 0.0), + payload=data_object, + ) + elif event_type == "credit_note.created" and invoice: + credit_note = self.repository.save_credit_note( + { + "invoice_id": invoice["invoice_id"], + "customer_account_id": invoice.get("customer_account_id"), + "account_id": invoice["account_id"], + "provider": "stripe", + "provider_credit_note_ref": data_object.get("id"), + "status": "issued", + "amount_usd": float(data_object.get("amount", 0) or 0) / 100.0, + "reason": data_object.get("reason"), + "credit_payload": data_object, + } + ) + processing_result["credit_note"] = credit_note + webhook = self._record_webhook( + event=event, + invoice_id=(processing_result.get("invoice") or {}).get("invoice_id") if isinstance(processing_result.get("invoice"), dict) else (invoice or {}).get("invoice_id") if invoice else None, + account_id=(invoice or {}).get("account_id") if invoice else None, + processing_result=processing_result, + ) + return {"webhook_event": webhook, **processing_result} + + def replay_webhook(self, provider_webhook_event_id: str) -> Dict[str, Any]: + event = self.repository.get_provider_webhook_event(provider_webhook_event_id) + # Limited replay: return stored event until full replay orchestration is needed. + return {"webhook_event": event, "replayed": False} + + def retry_invoice_payment(self, *, invoice_id: str, requested_by: str) -> Dict[str, Any]: + invoice = self.repository.get_invoice_issuance(invoice_id) + attempt = self.repository.save_payment_retry_attempt( + { + "invoice_id": invoice_id, + "customer_account_id": invoice.get("customer_account_id"), + "account_id": invoice["account_id"], + "provider": "stripe", + "status": "planned", + "retry_reason": "manual_retry", + "attempt_count": len(self.repository.list_payment_retry_attempts(invoice_id=invoice_id, limit=100)) + 1, + "next_retry_at": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(), + "retry_payload": {"requested_by": requested_by}, + } + ) + dunning = self.repository.save_dunning_event( + { + "invoice_id": invoice_id, + "customer_account_id": invoice.get("customer_account_id"), + "account_id": invoice["account_id"], + "status": "scheduled", + "step": "manual_retry_requested", + "event_payload": self._json_safe({"requested_by": requested_by}), + } + ) + return {"payment_retry_attempt": attempt, "dunning_event": dunning} diff --git a/src/narrativeos/services/training_signal.py b/src/narrativeos/services/training_signal.py index 35a4bbc..832f41c 100644 --- a/src/narrativeos/services/training_signal.py +++ b/src/narrativeos/services/training_signal.py @@ -8,6 +8,7 @@ from uuid import uuid4 from ..models import EvaluationReport +from ..persistence.db import SessionRow from ..persistence.repositories import SQLAlchemyPlatformRepository from ..schemas import validate_payload @@ -23,6 +24,21 @@ "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), +) +LONGFORM_1000_REVIEW_WINDOWS = ( + ("1-80", 1, 80), + ("420-580", 420, 580), + ("920-1000", 920, 1000), +) class TrainingSignalService: @@ -35,12 +51,22 @@ def _utcnow(self) -> str: def _parse_timestamp(self, value: Optional[str]) -> datetime: if not value: return datetime.fromtimestamp(0, tz=timezone.utc) + if isinstance(value, datetime): + parsed = value + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) 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) + def _serialize_timestamp(self, value: Any) -> Optional[str]: + if value is None or value == "": + return None + return self._parse_timestamp(value).isoformat() + def _selected_versions(self, *, world_id: Optional[str], world_version_id: Optional[str]) -> List[Dict[str, Any]]: if world_version_id: version = self.repository.get_world_version(world_version_id) @@ -88,6 +114,97 @@ def _normalize_source_ref(self, payload: Dict[str, Any], *, chapter_id: str) -> "chapter_id": str(source_ref.get("chapter_id") or chapter_id), } + 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_sampling_plan_from_reports( + self, + report_payloads: Sequence[Dict[str, Any]], + *, + world_id: str, + world_version_id: str, + windows: Sequence[Tuple[str, int, int]], + reason_prefix: str, + ) -> List[Dict[str, Any]]: + chapter_ids = [str(item.get("chapter_id") or "") for item in report_payloads] + available_indices = [ + 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]) + 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() + 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, + "priority": priority, + "reason": f"{reason_prefix}_{window_label}", + "available_chapter_max": max_index, + } + ) + return plan + + def _longform_250_sampling_plan_from_reports( + self, + report_payloads: Sequence[Dict[str, Any]], + *, + world_id: str, + world_version_id: str, + ) -> List[Dict[str, Any]]: + return self._longform_sampling_plan_from_reports( + report_payloads, + world_id=world_id, + world_version_id=world_version_id, + windows=LONGFORM_250_REVIEW_WINDOWS, + reason_prefix="longform_250_window", + ) + + def _longform_500_sampling_plan_from_reports( + self, + report_payloads: Sequence[Dict[str, Any]], + *, + world_id: str, + world_version_id: str, + ) -> List[Dict[str, Any]]: + return self._longform_sampling_plan_from_reports( + report_payloads, + world_id=world_id, + world_version_id=world_version_id, + windows=LONGFORM_500_REVIEW_WINDOWS, + reason_prefix="longform_500_window", + ) + + def _longform_1000_sampling_plan_from_reports( + self, + report_payloads: Sequence[Dict[str, Any]], + *, + world_id: str, + world_version_id: str, + ) -> List[Dict[str, Any]]: + return self._longform_sampling_plan_from_reports( + report_payloads, + world_id=world_id, + world_version_id=world_version_id, + windows=LONGFORM_1000_REVIEW_WINDOWS, + reason_prefix="longform_1000_window", + ) + def _review_sample_ingestion_key(self, sample: Dict[str, Any]) -> str: stable_fields = [ str(sample.get("world_version_id") or ""), @@ -319,6 +436,14 @@ def _review_sample_from_report(self, report_payload: Dict[str, Any], *, world_id validate_payload(sample, "review_sample.schema.json") 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) + # Benchmarks and offline simulations do not always have a persisted + # session row; drop the transient session id so ingestion can validate + # against the world version without creating fake session records. + sample["session_id"] = None + return self.save_review_sample(sample) + def save_review_sample(self, payload: Dict[str, Any]) -> Dict[str, Any]: source = str(payload.get("source") or "human_review") if source not in {"evaluation_report_auto", "human_review"}: @@ -619,7 +744,7 @@ def pack_quality_trends( "rewrite_rate": float(evaluation_summary.get("rewrite_rate", 0.0)), "block_rate": float(evaluation_summary.get("block_rate", 0.0)), "cross_pack_pass_rate": float(simulation.get("cross_pack_summary", {}).get("cross_pack_pass_rate", 0.0)), - "updated_at": version_meta.get("updated_at"), + "updated_at": self._serialize_timestamp(version_meta.get("updated_at")), } trends.append(trend) return self._apply_incremental_window( @@ -703,6 +828,278 @@ def review_sample_backlog( backlog.sort(key=lambda item: (priority_rank[item["priority"]], -self._parse_timestamp(item["created_at"]).timestamp())) return backlog[:limit] if limit is not None else backlog + def longform_250_human_review_closeout( + self, + *, + world_id: Optional[str] = None, + world_version_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> Dict[str, Any]: + versions = self._selected_versions(world_id=world_id, world_version_id=world_version_id) + human_samples = self.list_review_samples( + world_id=world_id, + world_version_id=world_version_id, + source="human_review", + limit=None, + ) + human_targets = { + (str(sample.get("world_version_id") or ""), self._chapter_index_from_id(sample.get("chapter_id"))) + for sample in human_samples + if self._chapter_index_from_id(sample.get("chapter_id")) > 0 + } + planned_target_count = 0 + human_reviewed_target_count = 0 + backlog: List[Dict[str, Any]] = [] + window_coverage: Dict[str, Dict[str, int]] = {} + for version_meta in versions: + version = self.repository.get_world_version(version_meta["world_version_id"]) + simulation = dict(version.simulation_report_json or {}) + if int(simulation.get("completed_chapters", 0) or 0) < 250 and not simulation.get("longform_250_summary"): + continue + chapter_reports = list(simulation.get("chapter_evaluations", [])) + plan = self._longform_250_sampling_plan_from_reports( + chapter_reports, + world_id=version.world_id, + world_version_id=version.world_version_id, + ) + for target in plan: + planned_target_count += 1 + window_label = str(target.get("window_label") or "") + bucket = window_coverage.setdefault(window_label, {"target_count": 0, "human_reviewed_count": 0}) + bucket["target_count"] += 1 + key = (str(target.get("world_version_id") or ""), int(target.get("chapter_index", 0) or 0)) + report_payload = next( + ( + dict(item) + for item in chapter_reports + if self._chapter_index_from_id(item.get("chapter_id")) == key[1] + ), + {}, + ) + if key in human_targets: + human_reviewed_target_count += 1 + bucket["human_reviewed_count"] += 1 + continue + backlog.append( + { + **dict(target), + "chapter_id": report_payload.get("chapter_id"), + "decision": dict(report_payload.get("decision") or {}).get("decision"), + "score_overall": float(dict(report_payload.get("scores") or {}).get("overall_score", 0.0) or 0.0), + "issue_codes": [ + issue.get("issue_code") + for issue in report_payload.get("issues", []) + if issue.get("issue_code") + ], + "summary": str(report_payload.get("summary") or ""), + "recommended_action": "capture_longform_250_human_review", + } + ) + human_closeout_ready = planned_target_count > 0 and human_reviewed_target_count >= planned_target_count + human_closeout_status = "closed" if human_closeout_ready else ("partial" if human_reviewed_target_count > 0 else "watch") + backlog.sort( + key=lambda item: ( + str(item.get("world_id") or ""), + str(item.get("window_label") or ""), + int(item.get("priority", 99) or 99), + int(item.get("chapter_index", 0) or 0), + ) + ) + return { + "planned_target_count": planned_target_count, + "human_reviewed_target_count": human_reviewed_target_count, + "human_closeout_ready": human_closeout_ready, + "human_closeout_status": human_closeout_status, + "window_coverage": window_coverage, + "backlog": backlog[:limit] if limit is not None else backlog, + } + + def longform_500_human_review_closeout( + self, + *, + world_id: Optional[str] = None, + world_version_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> Dict[str, Any]: + versions = self._selected_versions(world_id=world_id, world_version_id=world_version_id) + human_samples = self.list_review_samples( + world_id=world_id, + world_version_id=world_version_id, + source="human_review", + limit=None, + ) + human_targets = { + (str(sample.get("world_version_id") or ""), self._chapter_index_from_id(sample.get("chapter_id"))) + for sample in human_samples + if self._chapter_index_from_id(sample.get("chapter_id")) > 0 + } + planned_target_count = 0 + human_reviewed_target_count = 0 + backlog: List[Dict[str, Any]] = [] + 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 version_meta in versions: + version = self.repository.get_world_version(version_meta["world_version_id"]) + simulation = dict(version.simulation_report_json or {}) + if int(simulation.get("completed_chapters", 0) or 0) < 500 and not simulation.get("longform_500_summary"): + continue + chapter_reports = list(simulation.get("chapter_evaluations", [])) + plan = self._longform_500_sampling_plan_from_reports( + chapter_reports, + world_id=version.world_id, + world_version_id=version.world_version_id, + ) + for target in plan: + planned_target_count += 1 + window_label = str(target.get("window_label") or "") + bucket = window_coverage.setdefault(window_label, {"target_count": 0, "human_reviewed_count": 0}) + bucket["target_count"] += 1 + if window_label == ending_window_label: + ending_window_target_count += 1 + key = (str(target.get("world_version_id") or ""), int(target.get("chapter_index", 0) or 0)) + report_payload = next( + ( + dict(item) + for item in chapter_reports + if self._chapter_index_from_id(item.get("chapter_id")) == key[1] + ), + {}, + ) + if key in human_targets: + human_reviewed_target_count += 1 + bucket["human_reviewed_count"] += 1 + if window_label == ending_window_label: + ending_window_human_reviewed_count += 1 + continue + backlog.append( + { + **dict(target), + "chapter_id": report_payload.get("chapter_id"), + "decision": dict(report_payload.get("decision") or {}).get("decision"), + "score_overall": float(dict(report_payload.get("scores") or {}).get("overall_score", 0.0) or 0.0), + "issue_codes": [ + issue.get("issue_code") + for issue in report_payload.get("issues", []) + if issue.get("issue_code") + ], + "summary": str(report_payload.get("summary") or ""), + "recommended_action": "capture_longform_500_human_review", + } + ) + human_closeout_ready = planned_target_count > 0 and human_reviewed_target_count >= planned_target_count + human_closeout_status = "closed" if human_closeout_ready else ("partial" if human_reviewed_target_count > 0 else "watch") + ending_window_human_closeout_ready = ( + ending_window_target_count > 0 and ending_window_human_reviewed_count >= ending_window_target_count + ) + backlog.sort( + key=lambda item: ( + str(item.get("world_id") or ""), + str(item.get("window_label") or ""), + int(item.get("priority", 99) or 99), + int(item.get("chapter_index", 0) or 0), + ) + ) + return { + "planned_target_count": planned_target_count, + "human_reviewed_target_count": human_reviewed_target_count, + "human_closeout_ready": human_closeout_ready, + "human_closeout_status": human_closeout_status, + "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, + "backlog": backlog[:limit] if limit is not None else backlog, + } + + def longform_1000_human_review_closeout( + self, + *, + world_id: Optional[str] = None, + world_version_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> Dict[str, Any]: + versions = self._selected_versions(world_id=world_id, world_version_id=world_version_id) + human_samples = self.list_review_samples( + world_id=world_id, + world_version_id=world_version_id, + source="human_review", + limit=None, + ) + human_targets = { + (str(sample.get("world_version_id") or ""), self._chapter_index_from_id(sample.get("chapter_id"))) + for sample in human_samples + if self._chapter_index_from_id(sample.get("chapter_id")) > 0 + } + planned_target_count = 0 + human_reviewed_target_count = 0 + backlog: List[Dict[str, Any]] = [] + window_coverage: Dict[str, Dict[str, int]] = {} + for version_meta in versions: + version = self.repository.get_world_version(version_meta["world_version_id"]) + simulation = dict(version.simulation_report_json or {}) + if int(simulation.get("completed_chapters", 0) or 0) < 1000 and not simulation.get("longform_1000_summary"): + continue + chapter_reports = list(simulation.get("chapter_evaluations", [])) + plan = self._longform_1000_sampling_plan_from_reports( + chapter_reports, + world_id=version.world_id, + world_version_id=version.world_version_id, + ) + for target in plan: + planned_target_count += 1 + window_label = str(target.get("window_label") or "") + bucket = window_coverage.setdefault(window_label, {"target_count": 0, "human_reviewed_count": 0}) + bucket["target_count"] += 1 + key = (str(target.get("world_version_id") or ""), int(target.get("chapter_index", 0) or 0)) + report_payload = next( + ( + dict(item) + for item in chapter_reports + if self._chapter_index_from_id(item.get("chapter_id")) == key[1] + ), + {}, + ) + if key in human_targets: + human_reviewed_target_count += 1 + bucket["human_reviewed_count"] += 1 + continue + backlog.append( + { + **dict(target), + "chapter_id": report_payload.get("chapter_id"), + "decision": dict(report_payload.get("decision") or {}).get("decision"), + "score_overall": float(dict(report_payload.get("scores") or {}).get("overall_score", 0.0) or 0.0), + "issue_codes": [ + issue.get("issue_code") + for issue in report_payload.get("issues", []) + if issue.get("issue_code") + ], + "summary": str(report_payload.get("summary") or ""), + "recommended_action": "capture_longform_1000_human_review", + } + ) + human_closeout_ready = planned_target_count > 0 and human_reviewed_target_count >= planned_target_count + human_closeout_status = "closed" if human_closeout_ready else ("partial" if human_reviewed_target_count > 0 else "watch") + backlog.sort( + key=lambda item: ( + str(item.get("world_id") or ""), + str(item.get("window_label") or ""), + int(item.get("priority", 99) or 99), + int(item.get("chapter_index", 0) or 0), + ) + ) + return { + "planned_target_count": planned_target_count, + "human_reviewed_target_count": human_reviewed_target_count, + "human_closeout_ready": human_closeout_ready, + "human_closeout_status": human_closeout_status, + "window_coverage": window_coverage, + "backlog": backlog[:limit] if limit is not None else backlog, + } + def issue_fix_pair_backlog( self, *, @@ -1255,6 +1652,106 @@ def analytics_examples(self, continue_churn_events: Sequence[Dict[str, Any]]) -> ) return sorted(examples, key=lambda item: item["example_id"]) + def supplement_real_continuation_samples( + self, + *, + world_version_ids: Sequence[str], + target_sample_count_per_version: int = 8, + target_negative_samples: int = 2, + chapters_per_session: int = 2, + max_sessions_per_version: int = 12, + reader_id_prefix: str = "continuation_probe", + stale_hours: int = 48, + ) -> Dict[str, Any]: + from .billing import BillingService + from .sessions import ReaderContinueCommand, SessionService + + billing = BillingService(self.repository) + session_service = SessionService(self.repository) + world_summaries: List[Dict[str, Any]] = [] + stale_timestamp = (datetime.now(timezone.utc) - timedelta(hours=max(1, stale_hours))).isoformat() + + for world_version_id in world_version_ids: + version = self.repository.get_world_version(world_version_id) + world_id = version.world_id + before_metrics = self.repository.aggregate_eval_metrics(world_version_id=world_version_id) + before_signal = dict(before_metrics.get("continuation_signal_summary") or {}) + created_sessions: List[str] = [] + session_results: List[Dict[str, Any]] = [] + guard = 0 + current_signal = before_signal + while ( + ( + int(current_signal.get("sample_count", 0) or 0) < int(target_sample_count_per_version) + or int(current_signal.get("negative_count", 0) or 0) < int(target_negative_samples) + ) + and guard < int(max_sessions_per_version) + ): + reader_id = f"{reader_id_prefix}_{world_id}_{guard + 1}" + session_payload = session_service.create_session(world_id, reader_id=reader_id) + session_id = str(session_payload["session_id"]) + created_sessions.append(session_id) + statuses: List[str] = [] + for _ in range(max(1, int(chapters_per_session))): + result = session_service.continue_story( + ReaderContinueCommand(session_id=session_id, freeform_intent="继续读下去。"), + reader_id=reader_id, + ) + status = str(result.get("status") or "unknown") + if status == "payment_required": + billing.grant_subscription( + { + "account_id": reader_id, + "tier_id": "play_pass", + "provider": "ops_manual", + "status": "active", + } + ) + result = session_service.continue_story( + ReaderContinueCommand(session_id=session_id, freeform_intent="继续读下去。"), + reader_id=reader_id, + ) + status = str(result.get("status") or "unknown") + statuses.append(status) + if status != "ok": + break + with self.repository.SessionLocal() as session: + row = session.get(SessionRow, session_id) + if row is not None: + row.updated_at = stale_timestamp + session.commit() + session_results.append( + { + "session_id": session_id, + "reader_id": reader_id, + "continue_statuses": statuses, + "forced_stale_at": stale_timestamp, + } + ) + guard += 1 + current_signal = dict(self.repository.aggregate_eval_metrics(world_version_id=world_version_id).get("continuation_signal_summary") or {}) + + after_metrics = self.repository.aggregate_eval_metrics(world_version_id=world_version_id) + world_summaries.append( + { + "world_id": world_id, + "world_version_id": world_version_id, + "before_signal_summary": before_signal, + "after_signal_summary": dict(after_metrics.get("continuation_signal_summary") or {}), + "after_calibration": dict(after_metrics.get("q03_q09_calibration") or {}), + "created_session_count": len(created_sessions), + "created_sessions": created_sessions, + "session_results": session_results, + } + ) + + return { + "target_sample_count_per_version": int(target_sample_count_per_version), + "target_negative_samples": int(target_negative_samples), + "chapters_per_session": int(chapters_per_session), + "world_summaries": world_summaries, + } + def _split_leakage_warning(self, examples: Sequence[Dict[str, Any]], stable_key: str) -> bool: seen: Dict[str, str] = {} for item in examples: @@ -1342,7 +1839,7 @@ def continue_churn_events( "world_version_id": event.get("world_version_id") or "", "chapter_index": payload_json.get("chapter_index"), "access_tier": payload_json.get("access_tier"), - "occurred_at": event.get("occurred_at"), + "occurred_at": self._serialize_timestamp(event.get("occurred_at")), "payload_json": payload_json, } validate_payload(normalized_event, "continue_churn_event.schema.json") diff --git a/src/narrativeos/services/wave_activation_controller.py b/src/narrativeos/services/wave_activation_controller.py new file mode 100644 index 0000000..13501aa --- /dev/null +++ b/src/narrativeos/services/wave_activation_controller.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +import json +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from .commercial_audit import CommercialAuditService +from .launch_command_center import LaunchCommandCenterService +from .production_acceptance import ProductionAcceptanceService +from .production_preflight import ProductionPreflightService +from .production_signoff import ProductionSignoffService +from ..persistence.repositories import SQLAlchemyPlatformRepository + + +ROOT = Path(__file__).resolve().parents[3] + + +class WaveActivationControllerService: + def __init__( + self, + repository: SQLAlchemyPlatformRepository, + *, + production_signoff_service: ProductionSignoffService, + production_acceptance_service: ProductionAcceptanceService, + production_preflight_service: ProductionPreflightService, + launch_command_center_service: LaunchCommandCenterService, + commercial_audit_service: CommercialAuditService, + base_dir: Optional[Path] = None, + ) -> None: + self.repository = repository + self.production_signoff = production_signoff_service + self.production_acceptance = production_acceptance_service + self.production_preflight = production_preflight_service + self.launch_command_center = launch_command_center_service + self.commercial_audit = commercial_audit_service + self.base_dir = Path(base_dir or ROOT) + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _artifacts_root(self) -> Path: + return self.base_dir / "artifacts" + + def _current_signoff_detail(self, launch_wave: str) -> Optional[Dict[str, Any]]: + acceptance = self.production_acceptance.list_acceptance_records(launch_wave=launch_wave, limit=100) + for record in list(acceptance.get("acceptance_records") or []): + signoff_id = record.get("signoff_id") + if signoff_id: + try: + return self.production_signoff.signoff_detail(signoff_id=signoff_id) + except KeyError: + continue + current = self.production_signoff.current_signoff_summary() + if current and current.get("signoff_id"): + return self.production_signoff.signoff_detail(signoff_id=current["signoff_id"]) + return None + + def _latest_preflight(self, launch_wave: str) -> Optional[Dict[str, Any]]: + return self.production_preflight.list_runs(launch_wave=launch_wave, limit=1).get("current_run") + + def _current_wave_status(self, launch_wave: str) -> Optional[Dict[str, Any]]: + return next(iter(self.repository.list_launch_wave_statuses(launch_wave=launch_wave, limit=1)), None) + + def _launch_accounts(self, launch_wave: str) -> List[Dict[str, Any]]: + latest: Dict[str, Dict[str, Any]] = {} + for row in list(self.repository.list_go_live_ready_accounts(launch_wave=launch_wave, limit=200)): + account_id = str(row.get("account_id") or "") + if not account_id: + continue + if account_id not in latest or str(row.get("updated_at") or "") > str(latest[account_id].get("updated_at") or ""): + latest[account_id] = row + return list(latest.values()) + + def _critical_alerts(self, launch_wave: str, account_ids: List[str]) -> List[Dict[str, Any]]: + center = self.launch_command_center.command_center(launch_wave=launch_wave) + alerts = [] + for panel_key in ("billing_anomaly_panel", "support_urgency_panel", "dispute_anomaly_panel", "dunning_anomaly_panel", "webhook_anomaly_panel"): + alerts.extend(list((center.get(panel_key) or {}).get("alerts") or [])) + return [ + alert + for alert in alerts + if str(alert.get("severity") or "") == "critical" + and (not account_ids or any(account_id in set(alert.get("account_ids") or []) for account_id in account_ids)) + ] + + def evaluate(self, *, launch_wave: str = "wave_1", actor_id: Optional[str] = None, actor_role: Optional[str] = None) -> Dict[str, Any]: + signoff = self._current_signoff_detail(launch_wave) + if signoff and actor_id and actor_role and str((signoff.get("signoff") or {}).get("status") or "") == "fully_signed": + existing_accounts = {str(item.get("account_id") or "") for item in self._launch_accounts(launch_wave) if item.get("account_id")} + for account_id in existing_accounts: + self.production_acceptance.generate_acceptance_record( + actor_id=actor_id, + actor_role=actor_role, + account_id=account_id, + launch_wave=launch_wave, + signoff_id=(signoff.get("signoff") or {}).get("signoff_id"), + ) + preflight = self._latest_preflight(launch_wave) + acceptance = self.production_acceptance.list_acceptance_records(launch_wave=launch_wave, limit=100) + launch_accounts = self._launch_accounts(launch_wave) + account_ids = [str(item.get("account_id") or "") for item in launch_accounts if item.get("account_id")] + wave_status = self._current_wave_status(launch_wave) + critical_alerts = self._critical_alerts(launch_wave, account_ids) + blockers: List[str] = [] + if not launch_accounts: + blockers.append("no_launch_customer_selected") + if not signoff or str((signoff.get("signoff") or {}).get("status") or "") != "fully_signed": + blockers.append("signoff_not_fully_signed") + if not preflight or str(preflight.get("status") or "") != "passed" or str(preflight.get("go_no_go") or "") != "go": + blockers.append("latest_preflight_not_go") + ready_accounts = [item for item in launch_accounts if str(item.get("status") or "") == "ready"] + if account_ids and len(ready_accounts) < len(account_ids): + blockers.append("launch_acceptance_not_ready") + if critical_alerts: + blockers.append("critical_launch_alert_present") + + current_status = str((wave_status or {}).get("status") or "blocked") + activation_state = "blocked" + auto_activation_eligible = False + if current_status == "active": + activation_state = "active" + elif current_status == "rollback_watch": + activation_state = "rollback_watch" + elif current_status == "armed": + activation_state = "armed" + auto_activation_eligible = len(blockers) == 0 + elif len(blockers) == 0: + activation_state = "activation_ready" + + result = { + "launch_wave": launch_wave, + "activation_state": activation_state, + "auto_activation_eligible": auto_activation_eligible, + "blockers": blockers, + "signoff_status": (signoff or {}).get("signoff", {}).get("status"), + "preflight_status": (preflight or {}).get("status"), + "go_no_go": (preflight or {}).get("go_no_go"), + "ready_account_count": len(ready_accounts), + "launch_account_ids": account_ids, + "critical_alert_count": len(critical_alerts), + "critical_alerts": critical_alerts, + "current_wave_status": wave_status, + "artifact_refs": self._latest_activation_artifact_refs() if activation_state == "active" else {}, + } + if auto_activation_eligible and actor_id and actor_role: + return self._activate( + actor_id=actor_id, + actor_role=actor_role, + launch_wave=launch_wave, + state_snapshot=result, + signoff_id=(signoff or {}).get("signoff", {}).get("signoff_id"), + ) + return result + + def _write_activation_bundle(self, *, launch_wave: str, state_snapshot: Dict[str, Any]) -> Dict[str, Any]: + run_id = f"wave_activation_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}" + bundle_dir = self._artifacts_root() / "wave_activation" / run_id + bundle_dir.mkdir(parents=True, exist_ok=True) + report = "\n".join( + [ + "# Wave Activation Report", + "", + f"- launch_wave: {launch_wave}", + f"- activation_state: {state_snapshot.get('activation_state')}", + f"- signoff_status: {state_snapshot.get('signoff_status')}", + f"- preflight_status: {state_snapshot.get('preflight_status')} / {state_snapshot.get('go_no_go')}", + f"- ready_account_count: {state_snapshot.get('ready_account_count')}", + f"- critical_alert_count: {state_snapshot.get('critical_alert_count')}", + f"- blockers: {state_snapshot.get('blockers')}", + ] + ) + (bundle_dir / "summary.json").write_text(json.dumps(state_snapshot, ensure_ascii=False, indent=2), encoding="utf-8") + (bundle_dir / "activation_report.md").write_text(report + "\n", encoding="utf-8") + latest_dir = self._artifacts_root() / "wave_activation" / "latest" + if latest_dir.exists(): + shutil.rmtree(latest_dir) + shutil.copytree(bundle_dir, latest_dir) + return { + "bundle_dir": str(bundle_dir), + "latest_dir": str(latest_dir), + "summary_json": str(bundle_dir / "summary.json"), + "activation_report_md": str(bundle_dir / "activation_report.md"), + } + + def _latest_activation_artifact_refs(self) -> Dict[str, Any]: + latest_dir = self._artifacts_root() / "wave_activation" / "latest" + if not latest_dir.exists(): + return {} + return { + "bundle_dir": str(latest_dir), + "latest_dir": str(latest_dir), + "summary_json": str(latest_dir / "summary.json"), + "activation_report_md": str(latest_dir / "activation_report.md"), + } + + def _activate( + self, + *, + actor_id: str, + actor_role: str, + launch_wave: str, + state_snapshot: Dict[str, Any], + signoff_id: Optional[str], + ) -> Dict[str, Any]: + for account in self._launch_accounts(launch_wave): + self.production_acceptance.generate_acceptance_record( + actor_id=actor_id, + actor_role=actor_role, + account_id=str(account.get("account_id")), + launch_wave=launch_wave, + signoff_id=signoff_id, + ) + wave_status = self.production_acceptance.update_launch_wave_status( + actor_id=actor_id, + actor_role=actor_role, + launch_wave=launch_wave, + status="active", + note="wave_activation_controller_activated", + )["launch_wave_status"] + activation_snapshot = { + **state_snapshot, + "activation_state": "active", + "current_wave_status": wave_status, + "activated_at": self._utcnow(), + } + artifact_refs = self._write_activation_bundle(launch_wave=launch_wave, state_snapshot=activation_snapshot) + event = self.repository.save_production_launch_event( + { + "launch_wave": launch_wave, + "account_id": None, + "event_category": "activation", + "event_type": "wave_activated", + "phase": "launch_day", + "severity": "info", + "related_object_type": "launch_wave_status", + "related_object_id": wave_status["launch_wave_status_id"], + "event_payload": { + "signoff_id": signoff_id, + "artifact_refs": artifact_refs, + }, + } + ) + self.commercial_audit.record_audit_log( + actor_id=actor_id, + actor_role=actor_role, + account_id=None, + object_type="wave_activation", + object_id=wave_status["launch_wave_status_id"], + action_type="wave_activated", + source_surface="ops", + customer_visible_payload={}, + internal_payload={"activation_snapshot": activation_snapshot, "artifact_refs": artifact_refs, "launch_event": event}, + ) + return { + **activation_snapshot, + "artifact_refs": artifact_refs, + "launch_event": event, + } + + def arm(self, *, actor_id: str, actor_role: str, launch_wave: str = "wave_1") -> Dict[str, Any]: + current = self._current_wave_status(launch_wave) + wave_status = self.production_acceptance.update_launch_wave_status( + actor_id=actor_id, + actor_role=actor_role, + launch_wave=launch_wave, + status="armed", + note="wave_activation_controller_armed", + )["launch_wave_status"] + self.repository.save_production_launch_event( + { + "launch_wave": launch_wave, + "account_id": None, + "event_category": "activation", + "event_type": "wave_armed", + "phase": "go_no_go", + "severity": "info", + "related_object_type": "launch_wave_status", + "related_object_id": wave_status["launch_wave_status_id"], + "event_payload": {"previous_status": (current or {}).get("status")}, + } + ) + evaluated = self.evaluate(launch_wave=launch_wave, actor_id=actor_id, actor_role=actor_role) + return { + "launch_wave_status": wave_status, + "evaluation": evaluated, + } + + def mark_rollback_watch(self, *, actor_id: str, actor_role: str, launch_wave: str = "wave_1", note: Optional[str] = None) -> Dict[str, Any]: + wave_status = self.production_acceptance.update_launch_wave_status( + actor_id=actor_id, + actor_role=actor_role, + launch_wave=launch_wave, + status="rollback_watch", + note=note or "wave_activation_controller_rollback_watch", + )["launch_wave_status"] + event = self.repository.save_production_launch_event( + { + "launch_wave": launch_wave, + "account_id": None, + "event_category": "activation", + "event_type": "rollback_watch", + "phase": "launch_day", + "severity": "warning", + "related_object_type": "launch_wave_status", + "related_object_id": wave_status["launch_wave_status_id"], + "event_payload": {"note": note}, + } + ) + return {"launch_wave_status": wave_status, "launch_event": event} + + def summary(self, *, launch_wave: Optional[str] = None) -> Dict[str, Any]: + waves = self.repository.list_launch_wave_statuses(launch_wave=launch_wave, limit=100) + items = [] + for row in waves: + items.append(self.evaluate(launch_wave=str(row.get("launch_wave") or ""))) + current = items[0] if items else (self.evaluate(launch_wave=launch_wave) if launch_wave else None) + return { + "waves": items, + "current_wave": current, + "summary": { + "wave_count": len(items), + "active_count": len([item for item in items if item.get("activation_state") == "active"]), + "armed_count": len([item for item in items if item.get("activation_state") == "armed"]), + "activation_ready_count": len([item for item in items if item.get("activation_state") == "activation_ready"]), + "blocked_count": len([item for item in items if item.get("activation_state") == "blocked"]), + }, + } diff --git a/src/narrativeos/web/agent_studio.js b/src/narrativeos/web/agent_studio.js new file mode 100644 index 0000000..cbf1b3b --- /dev/null +++ b/src/narrativeos/web/agent_studio.js @@ -0,0 +1,539 @@ +// Agent Studio interactive workbench runtime. + +var AgentStudioRuntime = (() => { + const dom = AgentStudioDOM; + const { + api, + clearNode, + reportUiMessage, + setBusy, + downloadTextFile, + formatTimestamp, + } = UIShared; + + const QUICK_INTENTS = [ + "增加反转", + "增加细节", + "增加对白", + "放慢节奏", + "推进关系", + "制造误会", + "加强悬疑", + "不要收束", + ]; + + let initialized = false; + let currentChapterIndex = null; + let activeChoicePayload = null; + let originalAuthorRefresh = null; + const NOSBOOK_CONTENT_TYPE = "application/vnd.narrativeos.nosbook+json"; + + function escapeHtml(value) { + return String(value || "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + function clampLabel(value, fallback = "新的走向") { + const normalized = String(value || "").trim(); + return normalized ? normalized.slice(0, 28) : fallback; + } + + function activeAccountId() { + const identity = authorState.authorAuthSession?.identity || {}; + return ( + identity.account_id || + identity.actor_id || + AuthorDOM.authorAccountId?.value?.trim?.() || + "web_author" + ); + } + + function ensureAgentStudioState() { + if (!authorState.agentStudio) { + authorState.agentStudio = { quickIntents: QUICK_INTENTS }; + } + return authorState.agentStudio; + } + + function chapters() { + return [...(authorState.activeWorkDetail?.chapters || [])].sort( + (left, right) => Number(left.chapter_index || 0) - Number(right.chapter_index || 0) + ); + } + + function selectedChapter() { + const list = chapters(); + if (!list.length) return null; + if (!currentChapterIndex || !list.some((item) => Number(item.chapter_index) === Number(currentChapterIndex))) { + currentChapterIndex = Number(list[list.length - 1].chapter_index || 0); + } + return list.find((item) => Number(item.chapter_index) === Number(currentChapterIndex)) || list[list.length - 1]; + } + + function routeName(branch, index) { + if (index === 0 || branch.branch_kind === "mainline") return "主线"; + const letter = String.fromCharCode("A".charCodeAt(0) + Math.max(0, index - 1)); + return `路线 ${letter}:${clampLabel(branch.branch_origin_label || branch.branch_name)}`; + } + + function setGenerationStatus(message, kind = "info", detail = "") { + const state = ensureAgentStudioState(); + const previous = state.generationStatus || {}; + state.generationStatus = message + ? { + kind, + message, + detail, + startedAt: + previous.message === message && previous.kind === kind + ? previous.startedAt || new Date().toISOString() + : new Date().toISOString(), + } + : null; + if (!dom.generationStatus) return; + dom.generationStatus.innerHTML = message + ? `${escapeHtml(message)}${detail ? `${escapeHtml(detail)}` : ""}` + : ""; + dom.generationStatus.dataset.kind = kind; + dom.generationStatus.classList.toggle("is-hidden", !message); + } + + function productQualitySummary() { + const diagnostics = authorState.activeWorkDetail?.diagnostics_summary || {}; + const decision = String(diagnostics.latest_decision || diagnostics?.evaluation_summary?.decision || "pass"); + const needsAttention = decision && decision !== "pass" && decision !== "approved"; + const chaptersCount = Number(authorState.activeWorkDetail?.chapter_count || 0); + return [ + ["重复感", needsAttention ? "需留意" : "良好"], + ["场景细节", chaptersCount ? "充足" : "待生成"], + ["节奏", needsAttention ? "需观察" : "稳定"], + ["结尾风险", needsAttention ? "偏高" : "正常"], + ]; + } + + function renderQuality() { + if (!dom.quality) return; + clearNode(dom.quality); + for (const [label, value] of productQualitySummary()) { + const item = document.createElement("div"); + item.className = "agent-studio-quality-item"; + item.innerHTML = `${escapeHtml(label)}${escapeHtml(value)}`; + dom.quality.appendChild(item); + } + } + + function renderChapters() { + if (!dom.chapters) return; + const list = chapters(); + clearNode(dom.chapters, list.length ? "" : "还没有章节,先生成第一章。"); + list.forEach((chapter) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `agent-studio-chapter-link${Number(chapter.chapter_index) === Number(currentChapterIndex) ? " is-active" : ""}`; + button.innerHTML = `第 ${Number(chapter.chapter_index || 0)} 章${escapeHtml(chapter.chapter_title || "未命名章节")}`; + button.addEventListener("click", () => { + currentChapterIndex = Number(chapter.chapter_index || 0); + render(); + }); + dom.chapters.appendChild(button); + }); + } + + function renderBranches() { + const family = authorState.activeWorkDetail?.branch_family || []; + if (!dom.branches) return; + clearNode(dom.branches, family.length ? "" : "还没有分支路线。"); + if (dom.branchRouteSelect) { + dom.branchRouteSelect.innerHTML = ""; + } + family.forEach((branch, index) => { + const name = routeName(branch, index); + const card = document.createElement("article"); + card.className = `agent-studio-route-card${branch.is_active_line ? " is-active" : ""}`; + card.innerHTML = ` +
+ ${escapeHtml(name)} + ${Number(branch.chapter_count || 0)} 章 · 最近选择:${escapeHtml(branch.branch_origin_label || "持续主线")} +
+ + `; + card.querySelector("button")?.addEventListener("click", async () => { + if (branch.is_active_line) return; + await activateRoute(branch.work_id); + }); + dom.branches.appendChild(card); + if (dom.branchRouteSelect) { + const option = document.createElement("option"); + option.value = branch.work_id; + option.textContent = name; + option.selected = Boolean(branch.is_active_line); + dom.branchRouteSelect.appendChild(option); + } + }); + } + + function renderChoices(chapter) { + if (!dom.choiceCards) return; + const choices = chapter?.choices || chapter?.choices_json || []; + const impacts = chapter?.choice_impacts || []; + clearNode(dom.choiceCards, choices.length ? "" : "本章暂时没有下一步选择,可以直接写导演意图。"); + choices.forEach((choiceText, index) => { + const impact = impacts[index] || {}; + const card = document.createElement("article"); + card.className = "agent-studio-choice-card"; + card.innerHTML = ` +
+ ${escapeHtml(impact.label || choiceText || "继续推进")} +

${escapeHtml(impact.expected_effect || "会改变下一章的推进重点。")}

+
+
+ 风险:${escapeHtml(impact.risk_level || "中")} + 情感:${escapeHtml(impact.emotion || "波动")} + 节奏:${escapeHtml(impact.pacing || "推进")} + 关系:${escapeHtml(impact.relationship || "拉扯")} + 悬疑:${escapeHtml(impact.mystery || "维持")} +
+ + `; + card.querySelector("button")?.addEventListener("click", () => selectChoice(choiceText, impact)); + dom.choiceCards.appendChild(card); + }); + } + + function renderReader() { + const chapter = selectedChapter(); + const list = chapters(); + const activeIndex = list.findIndex((item) => Number(item.chapter_index) === Number(chapter?.chapter_index)); + if (dom.readerTitle) { + dom.readerTitle.textContent = chapter?.chapter_title || "等待第一章"; + } + if (dom.readerBody) { + dom.readerBody.innerHTML = chapter?.body + ? String(chapter.body) + .split(/\n{2,}/) + .map((para) => `

${escapeHtml(para)}

`) + .join("") + : "

设定故事目标后,第一章会出现在这里。

"; + } + if (dom.readerProgress) { + dom.readerProgress.textContent = chapter + ? `第 ${Number(chapter.chapter_index || 0)} 章 / ${Number(authorState.activeWorkDetail?.target_chapter_count || list.length || 1)} 章` + : "尚未开始"; + } + if (dom.readerFeedback) { + const title = chapter ? `第 ${Number(chapter.chapter_index || 0)} 章已完成` : "等待章节生成"; + const focus = chapter ? "本章强化了:人物冲突、场景细节" : "先填写创作启动页。"; + const next = chapter ? "下一章建议:继续追查 / 转入关系冲突 / 开启新分支" : "准备好后开始生成第一章。"; + dom.readerFeedback.innerHTML = `${escapeHtml(title)}${escapeHtml(focus)}${escapeHtml(next)}`; + } + if (dom.chapterPrev) { + dom.chapterPrev.disabled = activeIndex <= 0; + dom.chapterPrev.onclick = () => { + if (activeIndex > 0) { + currentChapterIndex = Number(list[activeIndex - 1].chapter_index || 0); + render(); + } + }; + } + if (dom.chapterNext) { + dom.chapterNext.disabled = activeIndex < 0 || activeIndex >= list.length - 1; + dom.chapterNext.onclick = () => { + if (activeIndex >= 0 && activeIndex < list.length - 1) { + currentChapterIndex = Number(list[activeIndex + 1].chapter_index || 0); + render(); + } + }; + } + renderChoices(chapter); + } + + function renderWorkbench() { + const hasWork = Boolean(authorState.activeWorkDetail?.work_id || authorState.activeWorkId); + dom.start?.classList.toggle("is-hidden", hasWork); + dom.workbench?.classList.toggle("is-hidden", !hasWork); + if (!hasWork) return; + if (dom.workTitle) dom.workTitle.textContent = authorState.activeWorkDetail?.title || "未命名作品"; + if (dom.workMeta) { + dom.workMeta.textContent = `${Number(authorState.activeWorkDetail?.chapter_count || 0)} 章 · ${authorState.activeWorkDetail?.branch_name || "主线"}`; + } + renderChapters(); + renderBranches(); + renderReader(); + renderQuality(); + } + + function render() { + renderWorkbench(); + } + + async function refreshStudio() { + if (typeof AuthorWorkspaceRuntime?.refreshAuthorSurface === "function") { + await AuthorWorkspaceRuntime.refreshAuthorSurface(); + } + render(); + } + + function setBriefFieldsFromStartup() { + const title = String(dom.title?.value || "").trim() || "未命名作品"; + const genre = String(dom.genre?.value || "urban_mystery"); + const readerGoal = String(dom.readerGoal?.value || "").trim(); + const lengthGoal = String(dom.length?.value || "").trim(); + const remixAllowed = dom.remix?.checked ? "允许读者二创。" : "不允许读者二创。"; + if (AuthorDOM.authorWorldTitle) AuthorDOM.authorWorldTitle.value = title; + if (AuthorDOM.authorGenrePreset && genre !== "custom") AuthorDOM.authorGenrePreset.value = genre; + if (AuthorDOM.authorLifeTheme) AuthorDOM.authorLifeTheme.value = readerGoal || "让读者持续想知道下一章会怎样"; + if (AuthorDOM.authorCorePremise) { + AuthorDOM.authorCorePremise.value = [ + `作品标题:${title}`, + `读者体验目标:${readerGoal || "稳定追更"}`, + `长度目标:${lengthGoal || "短篇"}`, + remixAllowed, + ].join("\n"); + } + } + + async function startStudio() { + const releaseBusy = dom.startButton ? setBusy(dom.startButton, "正在启动…") : null; + setGenerationStatus("第一章生成中", "pending", "正在建立作品设定、人物冲突和章节正文,可能需要一两分钟。"); + try { + setBriefFieldsFromStartup(); + const accountId = activeAccountId(); + const brief = AuthorWorkspaceRuntime.buildAuthorBriefPayload + ? AuthorWorkspaceRuntime.buildAuthorBriefPayload() + : { + genre_preset: String(dom.genre?.value || "urban_mystery"), + world_title: String(dom.title?.value || "未命名作品"), + core_premise: String(AuthorDOM.authorCorePremise?.value || ""), + life_theme: String(AuthorDOM.authorLifeTheme?.value || ""), + author_id: accountId, + account_id: accountId, + }; + const draft = await api("/v1/author/drafts/from-brief", { + method: "POST", + body: JSON.stringify({ brief: { ...brief, author_id: accountId, account_id: accountId }, account_id: accountId }), + }); + authorState.activeDraftVersionId = draft.world_version_id; + authorState.activeDraftDetail = await api(`/v1/author/drafts/${encodeURIComponent(draft.world_version_id)}`); + const work = await api("/v1/author/works", { + method: "POST", + body: JSON.stringify({ world_version_id: draft.world_version_id, account_id: accountId }), + }); + authorState.activeWorkId = work.work_id; + try { + authorState.activeWorkDetail = await api(`/v1/author/works/${encodeURIComponent(work.work_id)}/chapters/generate`, { + method: "POST", + body: JSON.stringify({ mode: "first", account_id: accountId }), + }); + } catch (error) { + authorState.activeWorkDetail = await api(`/v1/author/works/${encodeURIComponent(work.work_id)}`); + reportUiMessage(`第一章暂时没有入库:${error.message}`, "warning"); + } + currentChapterIndex = Number(authorState.activeWorkDetail?.active_chapter_index || authorState.activeWorkDetail?.chapter_count || 0) || null; + setGenerationStatus("第 1 章已完成。", "success", "本章已加入当前路线,可以继续阅读或选择下一步。"); + render(); + } catch (error) { + setGenerationStatus("启动失败,请检查账号权限后重试。", "error", "当前作品草稿已尽量保留,可以稍后重试。"); + reportUiMessage(`Agent Studio 启动失败:${error.message}`, "error"); + } finally { + releaseBusy?.(); + } + } + + async function activateRoute(workId) { + const normalized = String(workId || "").trim(); + if (!normalized) return; + setGenerationStatus("正在切换路线。", "pending", "阅读器会切到这条路线的最新章节。"); + const accountId = activeAccountId(); + const payload = await api(`/v1/author/works/${encodeURIComponent(normalized)}/activate-line`, { + method: "POST", + body: JSON.stringify({ account_id: accountId }), + }); + authorState.activeWorkId = payload.work_id || normalized; + authorState.activeWorkDetail = payload; + currentChapterIndex = Number(payload.active_chapter_index || payload.chapter_count || currentChapterIndex || 0) || null; + setGenerationStatus("路线已切换。", "success", "当前路线已成为导出主线。"); + render(); + } + + async function createBranchFromIntent(intentText) { + const intent = String(intentText || "").trim(); + const chapter = selectedChapter(); + if (!authorState.activeWorkId || !chapter || !intent) return null; + const sourceChapterIndex = Number(chapter.chapter_index || 0); + if (!sourceChapterIndex) return null; + const payload = await api(`/v1/author/works/${encodeURIComponent(authorState.activeWorkId)}/branches`, { + method: "POST", + body: JSON.stringify({ + source_chapter_index: sourceChapterIndex, + label: clampLabel(intent), + choice_source: activeChoicePayload?.label || intent, + account_id: activeAccountId(), + steering_directive: { + current_user_intent: intent, + summary: intent, + }, + }), + }); + authorState.activeWorkId = payload.work_id; + authorState.activeWorkDetail = payload; + currentChapterIndex = Number(payload.active_chapter_index || payload.chapter_count || sourceChapterIndex || 0); + return payload; + } + + async function generateNextChapter() { + if (!authorState.activeWorkId) { + reportUiMessage("请先从创作启动页创建作品。", "warning"); + return; + } + const releaseBusy = dom.generateButton ? setBusy(dom.generateButton, "生成中…") : null; + const intent = String(dom.directorIntent?.value || "").trim(); + setGenerationStatus("续写中", "pending", "正在沿导演意图推进下一章,完成后会自动跳到新章节。"); + try { + if (intent) { + await createBranchFromIntent(intent); + } + const mode = Number(authorState.activeWorkDetail?.chapter_count || 0) > 0 ? "next" : "first"; + authorState.activeWorkDetail = await api(`/v1/author/works/${encodeURIComponent(authorState.activeWorkId)}/chapters/generate`, { + method: "POST", + body: JSON.stringify({ mode, account_id: activeAccountId() }), + }); + currentChapterIndex = Number(authorState.activeWorkDetail?.active_chapter_index || authorState.activeWorkDetail?.chapter_count || 0) || currentChapterIndex; + dom.directorIntent.value = ""; + activeChoicePayload = null; + setGenerationStatus(`第 ${currentChapterIndex || ""} 章已完成。`, "success", "新章节已加入当前路线,下一步选择也已更新。"); + render(); + } catch (error) { + setGenerationStatus("续写失败,当前路线已保留。", "error", "可以调整导演意图后重试。"); + reportUiMessage(`续写失败:${error.message}`, "error"); + } finally { + releaseBusy?.(); + } + } + + function selectChoice(choiceText, impact) { + activeChoicePayload = { choiceText, ...impact }; + if (dom.directorIntent) { + dom.directorIntent.value = impact.director_intent_prefill || `沿着「${choiceText}」继续。`; + } + generateNextChapter(); + } + + async function createBranchOnly() { + const intent = String(dom.directorIntent?.value || "").trim(); + if (!intent) { + reportUiMessage("先写一句导演意图,再开新路线。", "warning"); + return; + } + const releaseBusy = dom.branchButton ? setBusy(dom.branchButton, "开分支…") : null; + setGenerationStatus("新路线创建中", "pending", "正在从当前章节保存分支。"); + try { + await createBranchFromIntent(intent); + setGenerationStatus("新路线已创建。", "success", "你可以继续写这条路线,或切回主线比较。"); + render(); + } catch (error) { + setGenerationStatus("新路线创建失败。", "error", "当前章节没有丢失,可以稍后再开路线。"); + reportUiMessage(`创建路线失败:${error.message}`, "error"); + } finally { + releaseBusy?.(); + } + } + + async function runValidation() { + if (!authorState.activeWorkId) return; + const releaseBusy = dom.validateButton ? setBusy(dom.validateButton, "校验中…") : null; + setGenerationStatus("正在校验当前导出主线。", "pending", "正在查看重复感、场景细节、节奏和结尾风险。"); + try { + authorState.activeWorkDetail = await api(`/v1/author/works/${encodeURIComponent(authorState.activeWorkId)}/diagnostics/run`, { + method: "POST", + body: JSON.stringify({ account_id: activeAccountId() }), + }); + setGenerationStatus(`校验完成:${formatTimestamp(new Date().toISOString())}`, "success", "质量状态已刷新。"); + render(); + } catch (error) { + setGenerationStatus("校验失败。", "error", "当前章节和路线仍然保留。"); + reportUiMessage(`校验失败:${error.message}`, "error"); + } finally { + releaseBusy?.(); + } + } + + async function exportNosbook() { + if (!authorState.activeWorkId) return; + const releaseBusy = dom.exportButton ? setBusy(dom.exportButton, "导出中…") : null; + try { + const payload = await api(`/v1/author/works/${encodeURIComponent(authorState.activeWorkId)}/export?format=nosbook&route=active`); + ensureAgentStudioState().lastNosbookExport = { + filename: payload.filename || "narrativeos-work.nosbook", + contentType: NOSBOOK_CONTENT_TYPE, + payload, + }; + downloadTextFile( + payload.filename || "narrativeos-work.nosbook", + JSON.stringify(payload, null, 2), + NOSBOOK_CONTENT_TYPE + ); + setGenerationStatus("已导出当前主线。", "success", ".nosbook 已按当前导出主线生成。"); + } catch (error) { + reportUiMessage(`导出失败:${error.message}`, "error"); + } finally { + releaseBusy?.(); + } + } + + function installAuthorRefreshHook() { + if (originalAuthorRefresh || typeof AuthorWorkspaceRuntime?.refreshAuthorSurface !== "function") return; + originalAuthorRefresh = AuthorWorkspaceRuntime.refreshAuthorSurface; + AuthorWorkspaceRuntime.refreshAuthorSurface = async (...args) => { + const result = await originalAuthorRefresh(...args); + render(); + return result; + }; + } + + function bindEvents() { + dom.startButton?.addEventListener("click", startStudio); + dom.advancedButton?.addEventListener("click", () => { + WorkspaceLayoutRuntime.setAuthorWorkspace("brief"); + ShellStatusRuntime.syncProductMode(); + }); + dom.generateButton?.addEventListener("click", generateNextChapter); + dom.branchButton?.addEventListener("click", createBranchOnly); + dom.validateButton?.addEventListener("click", runValidation); + dom.previewButton?.addEventListener("click", () => { + currentChapterIndex = Number(authorState.activeWorkDetail?.chapter_count || currentChapterIndex || 0) || null; + render(); + }); + dom.exportButton?.addEventListener("click", exportNosbook); + dom.branchRouteSelect?.addEventListener("change", () => activateRoute(dom.branchRouteSelect.value)); + dom.quickButtons.forEach((button) => { + button.addEventListener("click", () => { + const intent = String(button.dataset.agentStudioIntent || button.textContent || "").trim(); + const existing = String(dom.directorIntent?.value || "").trim(); + dom.directorIntent.value = existing ? `${existing};${intent}` : intent; + }); + }); + } + + function initializeAgentStudioRuntime() { + if (initialized) return; + initialized = true; + if (!dom.shell) return; + ensureAgentStudioState(); + installAuthorRefreshHook(); + bindEvents(); + render(); + } + + installAuthorRefreshHook(); + + return { + initializeAgentStudioRuntime, + render, + refreshStudio, + QUICK_INTENTS, + }; +})(); diff --git a/src/narrativeos/web/agent_studio_dom.js b/src/narrativeos/web/agent_studio_dom.js new file mode 100644 index 0000000..f0b0547 --- /dev/null +++ b/src/narrativeos/web/agent_studio_dom.js @@ -0,0 +1,36 @@ +// Agent Studio DOM registry kept separate from the legacy Author workspace surface. + +var AgentStudioDOM = (() => ({ + shell: DOMShared.query("#agent-studio-shell"), + start: DOMShared.query("#agent-studio-start"), + workbench: DOMShared.query("#agent-studio-workbench"), + title: DOMShared.query("#agent-studio-title"), + genre: DOMShared.query("#agent-studio-genre"), + length: DOMShared.query("#agent-studio-length"), + readerGoal: DOMShared.query("#agent-studio-reader-goal"), + remix: DOMShared.query("#agent-studio-remix"), + cover: DOMShared.query("#agent-studio-cover"), + startButton: DOMShared.query("#agent-studio-start-button"), + advancedButton: DOMShared.query("#agent-studio-advanced-button"), + workTitle: DOMShared.query("#agent-studio-work-title"), + workMeta: DOMShared.query("#agent-studio-work-meta"), + chapters: DOMShared.query("#agent-studio-chapters"), + branches: DOMShared.query("#agent-studio-branches"), + exportButton: DOMShared.query("#agent-studio-export"), + validateButton: DOMShared.query("#agent-studio-validate"), + previewButton: DOMShared.query("#agent-studio-preview"), + readerTitle: DOMShared.query("#agent-studio-reader-title"), + readerBody: DOMShared.query("#agent-studio-reader-body"), + readerProgress: DOMShared.query("#agent-studio-reader-progress"), + readerFeedback: DOMShared.query("#agent-studio-reader-feedback"), + chapterPrev: DOMShared.query("#agent-studio-chapter-prev"), + chapterNext: DOMShared.query("#agent-studio-chapter-next"), + choiceCards: DOMShared.query("#agent-studio-choice-cards"), + generationStatus: DOMShared.query("#agent-studio-generation-status"), + directorIntent: DOMShared.query("#agent-studio-director-intent"), + quickButtons: DOMShared.queryAll("[data-agent-studio-intent]"), + generateButton: DOMShared.query("#agent-studio-generate"), + branchButton: DOMShared.query("#agent-studio-create-branch"), + quality: DOMShared.query("#agent-studio-quality"), + branchRouteSelect: DOMShared.query("#agent-studio-route-select"), +}))(); diff --git a/src/narrativeos/web/app.js b/src/narrativeos/web/app.js deleted file mode 100644 index 71cdecd..0000000 --- a/src/narrativeos/web/app.js +++ /dev/null @@ -1,4457 +0,0 @@ -const appState = { - examples: [], - activeProduct: "reader", - shelfWorlds: [], - currentBundle: null, - worldId: null, - worldVersionId: null, - readerId: "reader_demo", - readerEntitlements: [], - readerCheckoutSession: null, - readerSubscription: null, - sessionPaywall: null, - sessionId: null, - currentState: null, - latestStep: null, - latestPreview: null, - replay: null, - intentPrefill: null, - sessionLibrary: [], - activeTone: "premium_prose", - activeView: "experience", - selectedReplayIndex: null, - selectedIntentOverride: null, - authorDrafts: [], - activeDraftVersionId: null, - activeDraftDetail: null, - authorValidationReport: null, - authorSimulationReport: null, - authorPreviousSimulationReport: null, - selectedAuthorRevisionIndex: null, - authorBriefTemplate: null, - authorAccessSnapshot: null, - authorWorkflowSummary: null, - authorCollaborationSummary: null, - authorReviewerInbox: null, - authorReviewerInboxNextCursor: null, - authorReviewerInboxHasMore: false, - authorReviewerInboxSearch: "", - authorNotificationPreferences: null, - authorAuthSession: null, - selectedAuthorThreadId: null, - authorInlineReplyDraft: "", - authorReviewerInboxVisibleNotificationIds: [], - opsReviewQueue: [], - opsWorldStatuses: [], - opsWorldHistories: [], - selectedOpsWorldId: null, - opsNavigationModel: null, - opsNavigationPinned: false, - opsInvestigationPinned: false, - opsRefreshRequestId: 0, - opsReleaseWorkspace: null, - opsMeters: [], - opsSchemaLifecycle: null, - opsDataIntegrity: null, - opsDataIntegrityRepair: null, - opsDeploymentHealthGate: null, - opsPreflightVerification: null, - opsDeploymentRunbook: null, - opsIncidentPlaybook: null, - opsRecoveryDrillResult: null, - opsAsyncJobSummary: null, - opsAsyncJobBootReconcile: null, - opsAsyncJobIncidents: null, - opsAsyncJobArtifactRetention: null, - opsAsyncJobOperatorHistory: null, - opsAsyncJobHandoffBundle: null, - opsAsyncJobRemoteShipping: null, - opsAsyncJobHandoffSla: null, - opsAsyncJobAdapterValidation: null, - opsAsyncJobAdapterHealthProbe: null, - opsAsyncJobNotificationReceipts: null, - opsAsyncNotificationRetryQueue: null, - opsAsyncRetryPolicies: null, - opsAsyncNotificationDeadLetterQueue: null, - opsAsyncRetryOutcomeDashboard: null, - opsAsyncJobs: [], - opsRuntimeIncidentSnapshot: null, - opsRuntimeReceipts: [], - opsProviderRouting: null, - opsProviderRollout: null, - opsProviderRuntimeMetrics: null, - opsSubscriptionAudit: null, - opsAccountDetail: null, - opsAccountWorkspace: null, - opsAlertsFeed: null, - opsAlertDetail: null, - selectedOpsAlertId: null, - opsGovernanceSnapshot: null, - opsGovernanceExport: null, - opsGovernanceDetail: null, - opsInvestigationBundle: null, - opsEvalMetrics: null, - opsCrossPackQuality: null, - opsLearnedDashboard: null, - opsLearnedImpact: null, - opsLearnedCadence: null, - opsLearnedAssistedGate: null, - opsLearnedAssistedRerank: null, - opsLearnedReviewQuality: null, - opsLearnedTrainingResult: null, - opsLearnedEvidence: null, - opsLearnedCompare: null, - opsLearnedRollout: null, - opsLearnedDataOps: null, - opsLearnedPromotion: null, - opsLearnedRerankerPromotion: null, - opsPreferenceSamples: [], - opsRankingSamples: [], - opsLearnedDetail: null, - opsLastActionImpact: null, - opsReviewCaptureTarget: null, -}; - -const els = { - appShell: document.querySelector("#app-shell"), - modeReader: document.querySelector("#mode-reader"), - modeAuthor: document.querySelector("#mode-author"), - modeOps: document.querySelector("#mode-ops"), - readerShell: document.querySelector("#reader-shell"), - authorShell: document.querySelector("#author-shell"), - opsShell: document.querySelector("#ops-shell"), - apiStatus: document.querySelector("#api-status"), - turnStatus: document.querySelector("#turn-status"), - worldStatus: document.querySelector("#world-status"), - sessionStatus: document.querySelector("#session-status"), - worldVersionStatus: document.querySelector("#world-version-status"), - accessTierStatus: document.querySelector("#access-tier-status"), - quoteStatus: document.querySelector("#quote-status"), - paywallBanner: document.querySelector("#paywall-banner"), - paywallBannerCopy: document.querySelector("#paywall-banner-copy"), - paywallBannerCheckout: document.querySelector("#paywall-banner-checkout"), - readerIdInput: document.querySelector("#reader-id-input"), - readerEntitlementType: document.querySelector("#reader-entitlement-type"), - readerSubscriptionStatus: document.querySelector("#reader-subscription-status"), - readerCreditBalance: document.querySelector("#reader-credit-balance"), - readerWorldUnlockStatus: document.querySelector("#reader-world-unlock-status"), - readerEntitlementReason: document.querySelector("#reader-entitlement-reason"), - grantEntitlementType: document.querySelector("#grant-entitlement-type"), - grantEntitlementBalance: document.querySelector("#grant-entitlement-balance"), - readerRefreshEntitlements: document.querySelector("#reader-refresh-entitlements"), - readerGrantEntitlement: document.querySelector("#reader-grant-entitlement"), - readerStartCheckout: document.querySelector("#reader-start-checkout"), - readerRetryPayment: document.querySelector("#reader-retry-payment"), - readerRenewSubscription: document.querySelector("#reader-renew-subscription"), - readerCancelSubscription: document.querySelector("#reader-cancel-subscription"), - readerEntitlementList: document.querySelector("#reader-entitlement-list"), - readerMembershipOffers: document.querySelector("#reader-membership-offers"), - readerCheckoutStatus: document.querySelector("#reader-checkout-status"), - worldGallery: document.querySelector("#world-gallery"), - sessionLibrary: document.querySelector("#session-library"), - previewRoute: document.querySelector("#preview-route"), - stepSession: document.querySelector("#step-session"), - resetOutput: document.querySelector("#reset-output"), - viewExperience: document.querySelector("#view-experience"), - viewStorybook: document.querySelector("#view-storybook"), - viewBackstage: document.querySelector("#view-backstage"), - experienceView: document.querySelector("#experience-view"), - storybookView: document.querySelector("#storybook-view"), - backstageView: document.querySelector("#backstage-view"), - worldTitle: document.querySelector("#world-title"), - worldDescription: document.querySelector("#world-description"), - featuredWorldTitle: document.querySelector("#featured-world-title"), - featuredWorldCopy: document.querySelector("#featured-world-copy"), - featuredWorldMood: document.querySelector("#featured-world-mood"), - featuredWorldHook: document.querySelector("#featured-world-hook"), - worldId: document.querySelector("#world-id"), - sessionId: document.querySelector("#session-id"), - lastEventTitle: document.querySelector("#last-event-title"), - suggestedInputs: document.querySelector("#suggested-inputs"), - playerInput: document.querySelector("#player-input"), - currentPressureText: document.querySelector("#current-pressure-text"), - lastIntentText: document.querySelector("#last-intent-text"), - suggestedPrefillText: document.querySelector("#suggested-prefill-text"), - factCount: document.querySelector("#fact-count"), - promiseCount: document.querySelector("#promise-count"), - tensionValue: document.querySelector("#tension-value"), - sceneWindow: document.querySelector("#scene-window"), - chosenEventTitle: document.querySelector("#chosen-event-title"), - chapterPanel: document.querySelector("#chapter-panel"), - bestRoute: document.querySelector("#best-route"), - storyFeed: document.querySelector("#story-feed"), - routePreview: document.querySelector("#route-preview"), - routePreviewPanel: document.querySelector("#route-preview-panel"), - candidateSummary: document.querySelector("#candidate-summary"), - scoredCandidates: document.querySelector("#scored-candidates"), - criticTrace: document.querySelector("#critic-trace"), - replayTimeline: document.querySelector("#replay-timeline"), - storyHero: document.querySelector("#story-hero"), - storyTitle: document.querySelector("#story-title"), - storyCaption: document.querySelector("#story-caption"), - storyQuote: document.querySelector("#story-quote"), - storyPrompt: document.querySelector("#story-prompt"), - storyMotif: document.querySelector("#story-motif"), - storyBeats: document.querySelector("#story-beats"), - storyDetails: document.querySelector("#story-details"), - storyProse: document.querySelector("#story-prose"), - storySequence: document.querySelector("#story-sequence"), - authorCreateDraft: document.querySelector("#author-create-draft"), - authorCreateDraftFromBrief: document.querySelector("#author-create-draft-from-brief"), - authorRefresh: document.querySelector("#author-refresh"), - authorAccountId: document.querySelector("#author-account-id"), - authorAuthActorId: document.querySelector("#author-auth-actor-id"), - authorAuthRole: document.querySelector("#author-auth-role"), - authorAuthDisplayName: document.querySelector("#author-auth-display-name"), - authorAuthPassword: document.querySelector("#author-auth-password"), - authorAuthRegister: document.querySelector("#author-auth-register"), - authorAuthLogin: document.querySelector("#author-auth-login"), - authorAuthLogout: document.querySelector("#author-auth-logout"), - authorAuthStatus: document.querySelector("#author-auth-status"), - authorActiveDraft: document.querySelector("#author-active-draft"), - authorValidationStatus: document.querySelector("#author-validation-status"), - authorSimulationChapters: document.querySelector("#author-simulation-chapters"), - authorTier: document.querySelector("#author-tier"), - authorStudioCredits: document.querySelector("#author-studio-credits"), - authorBriefAccess: document.querySelector("#author-brief-access"), - authorSimulateAccess: document.querySelector("#author-simulate-access"), - authorWorkflow: document.querySelector("#author-workflow"), - authorGenrePreset: document.querySelector("#author-genre-preset"), - authorWorldTitle: document.querySelector("#author-world-title"), - authorLeadName: document.querySelector("#author-lead-name"), - authorCounterpartName: document.querySelector("#author-counterpart-name"), - authorSupportingName: document.querySelector("#author-supporting-name"), - authorLifeTheme: document.querySelector("#author-life-theme"), - authorCorePremise: document.querySelector("#author-core-premise"), - authorLocations: document.querySelector("#author-locations"), - authorDraftList: document.querySelector("#author-draft-list"), - authorDraftDetail: document.querySelector("#author-draft-detail"), - authorValidationReport: document.querySelector("#author-validation-report"), - authorSimulationReport: document.querySelector("#author-simulation-report"), - authorAssetDiff: document.querySelector("#author-asset-diff"), - authorCompare: document.querySelector("#author-compare"), - authorVersionHistory: document.querySelector("#author-version-history"), - authorCollaboration: document.querySelector("#author-collaboration"), - authorReviewerInbox: document.querySelector("#author-reviewer-inbox"), - authorCharacterSelect: document.querySelector("#author-character-select"), - authorCharacterName: document.querySelector("#author-character-name"), - authorCharacterRole: document.querySelector("#author-character-role"), - authorCharacterLifeTheme: document.querySelector("#author-character-life-theme"), - authorCharacterCoreWound: document.querySelector("#author-character-core-wound"), - authorCharacterPublicSelf: document.querySelector("#author-character-public-self"), - authorCharacterShadowDesire: document.querySelector("#author-character-shadow-desire"), - authorCharacterVows: document.querySelector("#author-character-vows"), - authorSaveCharacter: document.querySelector("#author-save-character"), - authorSceneSelect: document.querySelector("#author-scene-select"), - authorSceneId: document.querySelector("#author-scene-id"), - authorSceneFunction: document.querySelector("#author-scene-function"), - authorSceneRequiredRoles: document.querySelector("#author-scene-required-roles"), - authorSceneBeats: document.querySelector("#author-scene-beats"), - authorSaveScene: document.querySelector("#author-save-scene"), - authorVoiceEditor: document.querySelector("#author-voice-editor"), - authorActionEditor: document.querySelector("#author-action-editor"), - authorSensoryEditor: document.querySelector("#author-sensory-editor"), - authorSceneEditor: document.querySelector("#author-scene-editor"), - authorStyleLexicon: document.querySelector("#author-style-lexicon"), - authorThemeLabels: document.querySelector("#author-theme-labels"), - authorHookTemplates: document.querySelector("#author-hook-templates"), - authorPacingRequireTurnTaking: document.querySelector("#author-pacing-require-turn-taking"), - authorPacingRequireCounterReaction: document.querySelector("#author-pacing-require-counter-reaction"), - authorPacingMinTurns: document.querySelector("#author-pacing-min-turns"), - authorPacingMaxTurns: document.querySelector("#author-pacing-max-turns"), - authorPacingMinimumExchanges: document.querySelector("#author-pacing-minimum-exchanges"), - authorPacingTurnPattern: document.querySelector("#author-pacing-turn-pattern"), - authorSceneHooks: document.querySelector("#author-scene-hooks"), - authorSaveCapabilities: document.querySelector("#author-save-capabilities"), - authorSaveStyleControls: document.querySelector("#author-save-style-controls"), - authorCommentAnchorType: document.querySelector("#author-comment-anchor-type"), - authorCommentAnchorKey: document.querySelector("#author-comment-anchor-key"), - authorCommentSeverity: document.querySelector("#author-comment-severity"), - authorCommentAssignee: document.querySelector("#author-comment-assignee"), - authorInboxReviewerId: document.querySelector("#author-inbox-reviewer-id"), - authorInboxStatusFilter: document.querySelector("#author-inbox-status-filter"), - authorInboxWorldVersionFilter: document.querySelector("#author-inbox-world-version-filter"), - authorInboxNotificationTypeFilter: document.querySelector("#author-inbox-notification-type-filter"), - authorInboxBlockingOnly: document.querySelector("#author-inbox-blocking-only"), - authorInboxSearch: document.querySelector("#author-inbox-search"), - authorRefreshReviewerInbox: document.querySelector("#author-refresh-reviewer-inbox"), - authorSearchReviewerInbox: document.querySelector("#author-search-reviewer-inbox"), - authorLoadMoreReviewerInbox: document.querySelector("#author-load-more-reviewer-inbox"), - authorBulkReadVisible: document.querySelector("#author-bulk-read-visible"), - authorBulkArchiveVisible: document.querySelector("#author-bulk-archive-visible"), - authorDraftWatcherId: document.querySelector("#author-draft-watcher-id"), - authorAddDraftWatcher: document.querySelector("#author-add-draft-watcher"), - authorRemoveDraftWatcher: document.querySelector("#author-remove-draft-watcher"), - authorNotificationPrefType: document.querySelector("#author-notification-pref-type"), - authorNotificationPrefInApp: document.querySelector("#author-notification-pref-in-app"), - authorNotificationPrefAsync: document.querySelector("#author-notification-pref-async"), - authorNotificationPrefSink: document.querySelector("#author-notification-pref-sink"), - authorNotificationPrefTarget: document.querySelector("#author-notification-pref-target"), - authorRefreshNotificationPreferences: document.querySelector("#author-refresh-notification-preferences"), - authorSaveNotificationPreference: document.querySelector("#author-save-notification-preference"), - authorNotificationPreferences: document.querySelector("#author-notification-preferences"), - authorCommentBody: document.querySelector("#author-comment-body"), - authorApprovalReviewer: document.querySelector("#author-approval-reviewer"), - authorApprovalReason: document.querySelector("#author-approval-reason"), - authorCreateCommentThread: document.querySelector("#author-create-comment-thread"), - authorRequestApproval: document.querySelector("#author-request-approval"), - authorApproveDraft: document.querySelector("#author-approve-draft"), - authorRequestChanges: document.querySelector("#author-request-changes"), - opsRefresh: document.querySelector("#ops-refresh"), - opsNavAccountId: document.querySelector("#ops-nav-account-id"), - opsNavWorldId: document.querySelector("#ops-nav-world-id"), - opsNavCaseId: document.querySelector("#ops-nav-case-id"), - opsNavAlertId: document.querySelector("#ops-nav-alert-id"), - opsSyncNavigation: document.querySelector("#ops-sync-navigation"), - opsFollowRecommendation: document.querySelector("#ops-follow-recommendation"), - opsNavigationSummary: document.querySelector("#ops-navigation-summary"), - opsNavigationTargets: document.querySelector("#ops-navigation-targets"), - opsNavigationActions: document.querySelector("#ops-navigation-actions"), - opsPendingCount: document.querySelector("#ops-pending-count"), - opsPublishedWorlds: document.querySelector("#ops-published-worlds"), - opsTotalCost: document.querySelector("#ops-total-cost"), - opsReviewQueue: document.querySelector("#ops-review-queue"), - opsWorldStatus: document.querySelector("#ops-world-status"), - opsReleaseWorldId: document.querySelector("#ops-release-world-id"), - opsRefreshReleaseWorkspace: document.querySelector("#ops-refresh-release-workspace"), - opsReleaseWorkspaceSummary: document.querySelector("#ops-release-workspace-summary"), - opsReleaseWorkspaceActions: document.querySelector("#ops-release-workspace-actions"), - opsReleaseWorkspaceTimeline: document.querySelector("#ops-release-workspace-timeline"), - opsReleaseWorkspaceDetails: document.querySelector("#ops-release-workspace-details"), - opsReviewHistory: document.querySelector("#ops-review-history"), - opsQualityTrend: document.querySelector("#ops-quality-trend"), - opsSchemaLifecycle: document.querySelector("#ops-schema-lifecycle"), - opsDataIntegrityActions: document.querySelector("#ops-data-integrity-actions"), - opsRunDataIntegrityDryRun: document.querySelector("#ops-run-data-integrity-dry-run"), - opsApplyDataIntegrityRepair: document.querySelector("#ops-apply-data-integrity-repair"), - opsDataIntegrity: document.querySelector("#ops-data-integrity"), - opsBackupLabel: document.querySelector("#ops-backup-label"), - opsRestorePath: document.querySelector("#ops-restore-path"), - opsRestoreRequestId: document.querySelector("#ops-restore-request-id"), - opsRestoreRequesterId: document.querySelector("#ops-restore-requester-id"), - opsRestoreApproverId: document.querySelector("#ops-restore-approver-id"), - opsRestoreReason: document.querySelector("#ops-restore-reason"), - opsCreateRuntimeBackup: document.querySelector("#ops-create-runtime-backup"), - opsRestoreRuntimeBackup: document.querySelector("#ops-restore-runtime-backup"), - opsRunRecoveryDrill: document.querySelector("#ops-run-recovery-drill"), - opsRequestRuntimeRestore: document.querySelector("#ops-request-runtime-restore"), - opsApproveRuntimeRestore: document.querySelector("#ops-approve-runtime-restore"), - opsRevokeRuntimeRestore: document.querySelector("#ops-revoke-runtime-restore"), - opsExecuteRuntimeRestore: document.querySelector("#ops-execute-runtime-restore"), - opsDeploymentHealthGate: document.querySelector("#ops-deployment-health-gate"), - opsPreflightVerification: document.querySelector("#ops-preflight-verification"), - opsDeploymentRunbook: document.querySelector("#ops-deployment-runbook"), - opsIncidentPlaybook: document.querySelector("#ops-incident-playbook"), - opsAsyncJobId: document.querySelector("#ops-async-job-id"), - opsAsyncJobNote: document.querySelector("#ops-async-job-note"), - opsNotificationReceiptId: document.querySelector("#ops-notification-receipt-id"), - opsExportHandoffBundle: document.querySelector("#ops-export-handoff-bundle"), - opsAcknowledgeAsyncJob: document.querySelector("#ops-acknowledge-async-job"), - opsShipRemoteArtifacts: document.querySelector("#ops-ship-remote-artifacts"), - opsEscalateHandoffSla: document.querySelector("#ops-escalate-handoff-sla"), - opsEnqueueNotificationRetry: document.querySelector("#ops-enqueue-notification-retry"), - opsProcessNotificationRetry: document.querySelector("#ops-process-notification-retry"), - opsRetryAsyncJob: document.querySelector("#ops-retry-async-job"), - opsResumeAsyncJob: document.querySelector("#ops-resume-async-job"), - opsRecoverAsyncJobs: document.querySelector("#ops-recover-async-jobs"), - opsEnforceAsyncRetention: document.querySelector("#ops-enforce-async-retention"), - opsRunColdStartDrill: document.querySelector("#ops-run-cold-start-drill"), - opsAsyncJobSummary: document.querySelector("#ops-async-job-summary"), - opsAsyncJobBootReconcile: document.querySelector("#ops-async-job-boot-reconcile"), - opsAsyncJobIncidents: document.querySelector("#ops-async-job-incidents"), - opsAsyncJobArtifactRetention: document.querySelector("#ops-async-job-artifact-retention"), - opsAsyncJobOperatorHistory: document.querySelector("#ops-async-job-operator-history"), - opsAsyncJobHandoffBundle: document.querySelector("#ops-async-job-handoff-bundle"), - opsAsyncJobAdapterValidation: document.querySelector("#ops-async-job-adapter-validation"), - opsAsyncJobAdapterHealthProbe: document.querySelector("#ops-async-job-adapter-health-probe"), - opsAsyncJobNotificationReceipts: document.querySelector("#ops-async-job-notification-receipts"), - opsAsyncNotificationRetryQueue: document.querySelector("#ops-async-job-notification-retry-queue"), - opsAsyncNotificationDeadLetterQueue: document.querySelector("#ops-async-job-dead-letter-queue"), - opsAsyncRetryOutcomeDashboard: document.querySelector("#ops-async-job-retry-outcome-dashboard"), - opsAsyncJobs: document.querySelector("#ops-async-jobs"), - opsRuntimeIncidentSnapshot: document.querySelector("#ops-runtime-incident-snapshot"), - opsRuntimeReceipts: document.querySelector("#ops-runtime-receipts"), - opsProviderRouting: document.querySelector("#ops-provider-routing"), - opsProviderRollout: document.querySelector("#ops-provider-rollout"), - opsProviderRolloutReviewerId: document.querySelector("#ops-provider-rollout-reviewer-id"), - opsProviderRolloutReason: document.querySelector("#ops-provider-rollout-reason"), - opsProviderRolloutBucket: document.querySelector("#ops-provider-rollout-bucket"), - opsProviderRolloutWorldAllowlist: document.querySelector("#ops-provider-rollout-world-allowlist"), - opsProviderCandidateCanary: document.querySelector("#ops-provider-candidate-canary"), - opsProviderCandidateActivate: document.querySelector("#ops-provider-candidate-activate"), - opsProviderCandidateRollback: document.querySelector("#ops-provider-candidate-rollback"), - opsProviderRendererCanary: document.querySelector("#ops-provider-renderer-canary"), - opsProviderRendererActivate: document.querySelector("#ops-provider-renderer-activate"), - opsProviderRendererRollback: document.querySelector("#ops-provider-renderer-rollback"), - opsProviderRuntimeMetrics: document.querySelector("#ops-provider-runtime-metrics"), - opsMeterList: document.querySelector("#ops-meter-list"), - opsAccountId: document.querySelector("#ops-account-id"), - opsWalletType: document.querySelector("#ops-wallet-type"), - opsTierId: document.querySelector("#ops-tier-id"), - opsWalletAmount: document.querySelector("#ops-wallet-amount"), - opsSubscriptionStatus: document.querySelector("#ops-subscription-status"), - opsEntitlementId: document.querySelector("#ops-entitlement-id"), - opsEntitlementReason: document.querySelector("#ops-entitlement-reason"), - opsBillingEventId: document.querySelector("#ops-billing-event-id"), - opsGrantSubscription: document.querySelector("#ops-grant-subscription"), - opsChangeSubscriptionState: document.querySelector("#ops-change-subscription-state"), - opsGrantWallet: document.querySelector("#ops-grant-wallet"), - opsDebitWallet: document.querySelector("#ops-debit-wallet"), - opsRevokeEntitlement: document.querySelector("#ops-revoke-entitlement"), - opsReconcileSubscription: document.querySelector("#ops-reconcile-subscription"), - opsRetrySubscriptionPayment: document.querySelector("#ops-retry-subscription-payment"), - opsReplayBillingEvent: document.querySelector("#ops-replay-billing-event"), - opsSubscriptionAudit: document.querySelector("#ops-subscription-audit"), - opsSubscriptionTimeline: document.querySelector("#ops-subscription-timeline"), - opsAccountWorkspaceSummary: document.querySelector("#ops-account-workspace-summary"), - opsAccountWorkspaceActions: document.querySelector("#ops-account-workspace-actions"), - opsAccountWorkspaceTimeline: document.querySelector("#ops-account-workspace-timeline"), - opsAccountDetail: document.querySelector("#ops-account-detail"), - opsAccountActivity: document.querySelector("#ops-account-activity"), - opsSupportSummary: document.querySelector("#ops-support-summary"), - opsSupportIssues: document.querySelector("#ops-support-issues"), - opsAlertAccountId: document.querySelector("#ops-alert-account-id"), - opsAlertStatusFilter: document.querySelector("#ops-alert-status-filter"), - opsAlertSeverityFilter: document.querySelector("#ops-alert-severity-filter"), - opsAlertNote: document.querySelector("#ops-alert-note"), - opsRefreshAlerts: document.querySelector("#ops-refresh-alerts"), - opsAcknowledgeAlert: document.querySelector("#ops-acknowledge-alert"), - opsResolveAlert: document.querySelector("#ops-resolve-alert"), - opsOpenAlertInvestigation: document.querySelector("#ops-open-alert-investigation"), - opsAlertSummary: document.querySelector("#ops-alert-summary"), - opsAlertFeed: document.querySelector("#ops-alert-feed"), - opsAlertDetail: document.querySelector("#ops-alert-detail"), - opsGovernanceCaseId: document.querySelector("#ops-governance-case-id"), - opsGovernanceCaseType: document.querySelector("#ops-governance-case-type"), - opsGovernanceTargetType: document.querySelector("#ops-governance-target-type"), - opsGovernanceTargetId: document.querySelector("#ops-governance-target-id"), - opsGovernanceSeverity: document.querySelector("#ops-governance-severity"), - opsGovernanceReviewerId: document.querySelector("#ops-governance-reviewer-id"), - opsGovernanceOwnerId: document.querySelector("#ops-governance-owner-id"), - opsGovernanceSummaryInput: document.querySelector("#ops-governance-summary-input"), - opsGovernanceNotes: document.querySelector("#ops-governance-notes"), - opsGovernanceStatus: document.querySelector("#ops-governance-status"), - opsGovernanceDueAt: document.querySelector("#ops-governance-due-at"), - opsGovernancePolicyLabels: document.querySelector("#ops-governance-policy-labels"), - opsGovernanceDisposition: document.querySelector("#ops-governance-disposition"), - opsGovernanceEvidenceTitle: document.querySelector("#ops-governance-evidence-title"), - opsGovernanceEvidencePreview: document.querySelector("#ops-governance-evidence-preview"), - opsGovernanceRestrictionType: document.querySelector("#ops-governance-restriction-type"), - opsGovernanceRestrictionExpiresAt: document.querySelector("#ops-governance-restriction-expires-at"), - opsCreateGovernanceCase: document.querySelector("#ops-create-governance-case"), - opsAssignGovernanceCase: document.querySelector("#ops-assign-governance-case"), - opsAddGovernanceEvidence: document.querySelector("#ops-add-governance-evidence"), - opsUpdateGovernanceCase: document.querySelector("#ops-update-governance-case"), - opsApplyGovernanceRestriction: document.querySelector("#ops-apply-governance-restriction"), - opsReleaseGovernanceRestriction: document.querySelector("#ops-release-governance-restriction"), - opsExportGovernanceAudit: document.querySelector("#ops-export-governance-audit"), - opsGovernanceSummary: document.querySelector("#ops-governance-summary"), - opsGovernanceCases: document.querySelector("#ops-governance-cases"), - opsGovernanceExport: document.querySelector("#ops-governance-export"), - opsGovernanceDetail: document.querySelector("#ops-governance-detail"), - opsAccountAuditSummary: document.querySelector("#ops-account-audit-summary"), - opsAccountAuditTrail: document.querySelector("#ops-account-audit-trail"), - opsInvestigationAccountId: document.querySelector("#ops-investigation-account-id"), - opsInvestigationWorldVersionId: document.querySelector("#ops-investigation-world-version-id"), - opsInvestigationCaseId: document.querySelector("#ops-investigation-case-id"), - opsRunInvestigation: document.querySelector("#ops-run-investigation"), - opsExportInvestigationTrace: document.querySelector("#ops-export-investigation-trace"), - opsInvestigationSummary: document.querySelector("#ops-investigation-summary"), - opsInvestigationTimeline: document.querySelector("#ops-investigation-timeline"), - opsInvestigationEvidence: document.querySelector("#ops-investigation-evidence"), - opsEvalMetrics: document.querySelector("#ops-eval-metrics"), - opsCrossPackQuality: document.querySelector("#ops-cross-pack-quality"), - opsLearnedDashboard: document.querySelector("#ops-learned-dashboard"), - opsLearnedImpact: document.querySelector("#ops-learned-impact"), - opsLearnedCadence: document.querySelector("#ops-learned-cadence"), - opsLearnedAssistedGate: document.querySelector("#ops-learned-assisted-gate"), - opsLearnedAssistedRerank: document.querySelector("#ops-learned-assisted-rerank"), - opsLearnedReviewQuality: document.querySelector("#ops-learned-review-quality"), - opsAssistedGateReviewerId: document.querySelector("#ops-assisted-gate-reviewer-id"), - opsAssistedGateReason: document.querySelector("#ops-assisted-gate-reason"), - opsAssistedGateBucket: document.querySelector("#ops-assisted-gate-bucket"), - opsAssistedGateConfidence: document.querySelector("#ops-assisted-gate-confidence"), - opsAssistedGateWorldAllowlist: document.querySelector("#ops-assisted-gate-world-allowlist"), - opsSetAssistedShadow: document.querySelector("#ops-set-assisted-shadow"), - opsSetAssistedActive: document.querySelector("#ops-set-assisted-active"), - opsDisableAssistedGate: document.querySelector("#ops-disable-assisted-gate"), - opsAssistedRerankReviewerId: document.querySelector("#ops-assisted-rerank-reviewer-id"), - opsAssistedRerankReason: document.querySelector("#ops-assisted-rerank-reason"), - opsAssistedRerankBucket: document.querySelector("#ops-assisted-rerank-bucket"), - opsAssistedRerankConfidence: document.querySelector("#ops-assisted-rerank-confidence"), - opsAssistedRerankCandidateWindow: document.querySelector("#ops-assisted-rerank-candidate-window"), - opsAssistedRerankMaxScoreGap: document.querySelector("#ops-assisted-rerank-max-score-gap"), - opsAssistedRerankWorldAllowlist: document.querySelector("#ops-assisted-rerank-world-allowlist"), - opsSetAssistedRerankShadow: document.querySelector("#ops-set-assisted-rerank-shadow"), - opsSetAssistedRerankActive: document.querySelector("#ops-set-assisted-rerank-active"), - opsDisableAssistedRerank: document.querySelector("#ops-disable-assisted-rerank"), - opsRunEvaluatorTraining: document.querySelector("#ops-run-evaluator-training"), - opsRunRerankerTraining: document.querySelector("#ops-run-reranker-training"), - opsRunBothTraining: document.querySelector("#ops-run-both-training"), - opsLearnedTraining: document.querySelector("#ops-learned-training"), - opsLearnedEvidence: document.querySelector("#ops-learned-evidence"), - opsLearnedCompare: document.querySelector("#ops-learned-compare"), - opsLearnedRollout: document.querySelector("#ops-learned-rollout"), - opsLearnedDataOps: document.querySelector("#ops-learned-data-ops"), - opsLearnedPromotion: document.querySelector("#ops-learned-promotion"), - opsLearnedRerankerPromotion: document.querySelector("#ops-learned-reranker-promotion"), - opsPromotionReviewerId: document.querySelector("#ops-promotion-reviewer-id"), - opsPromotionReason: document.querySelector("#ops-promotion-reason"), - opsApprovePromotion: document.querySelector("#ops-approve-promotion"), - opsRevokePromotion: document.querySelector("#ops-revoke-promotion"), - opsRerankerPromotionReviewerId: document.querySelector("#ops-reranker-promotion-reviewer-id"), - opsRerankerPromotionReason: document.querySelector("#ops-reranker-promotion-reason"), - opsApproveRerankerPromotion: document.querySelector("#ops-approve-reranker-promotion"), - opsRevokeRerankerPromotion: document.querySelector("#ops-revoke-reranker-promotion"), - opsLearnedWorlds: document.querySelector("#ops-learned-worlds"), - opsLearnedIssues: document.querySelector("#ops-learned-issues"), - opsLearnedDetail: document.querySelector("#ops-learned-detail"), - opsReviewSampleBacklog: document.querySelector("#ops-review-sample-backlog"), - opsPairCoverageBacklog: document.querySelector("#ops-pair-coverage-backlog"), - opsReviewCaptureContext: document.querySelector("#ops-review-capture-context"), - opsLastActionImpact: document.querySelector("#ops-last-action-impact"), - opsReviewerId: document.querySelector("#ops-reviewer-id"), - opsReviewScore: document.querySelector("#ops-review-score"), - opsReviewIssueCodes: document.querySelector("#ops-review-issue-codes"), - opsReviewNotes: document.querySelector("#ops-review-notes"), - opsReviewWouldContinue: document.querySelector("#ops-review-would-continue"), - opsReviewWouldPay: document.querySelector("#ops-review-would-pay"), - opsSubmitReviewCapture: document.querySelector("#ops-submit-review-capture"), - opsPreferenceLeftRevisionId: document.querySelector("#ops-preference-left-revision-id"), - opsPreferenceRightRevisionId: document.querySelector("#ops-preference-right-revision-id"), - opsPreferencePreferredRevisionId: document.querySelector("#ops-preference-preferred-revision-id"), - opsPreferenceStrength: document.querySelector("#ops-preference-strength"), - opsPreferenceNotes: document.querySelector("#ops-preference-notes"), - opsSubmitPreferenceCapture: document.querySelector("#ops-submit-preference-capture"), - opsPreferenceSamples: document.querySelector("#ops-preference-samples"), - opsRankingRevisionIds: document.querySelector("#ops-ranking-revision-ids"), - opsRankingNotes: document.querySelector("#ops-ranking-notes"), - opsSubmitRankingCapture: document.querySelector("#ops-submit-ranking-capture"), - opsRankingSamples: document.querySelector("#ops-ranking-samples"), - tonePills: [...document.querySelectorAll(".tone-pill")], - suggestionTemplate: document.querySelector("#suggested-input-template"), - listCardTemplate: document.querySelector("#list-card-template"), -}; - -async function api(path, options = {}) { - const shouldAttachAuthorToken = - Boolean(appState.authorAuthSession?.accessToken) && - ( - path.startsWith("/v1/author") || - path.startsWith("/v1/ops") || - (path.startsWith("/v1/auth") && !path.startsWith("/v1/auth/login") && !path.startsWith("/v1/auth/register")) - ); - const response = await fetch(path, { - headers: { - "Content-Type": "application/json", - ...(shouldAttachAuthorToken ? { Authorization: `Bearer ${appState.authorAuthSession.accessToken}` } : {}), - ...(options.headers || {}), - }, - ...options, - }); - if (!response.ok) { - let detail = response.statusText; - try { - const payload = await response.json(); - detail = payload.detail || JSON.stringify(payload); - } catch (error) { - detail = await response.text(); - } - throw new Error(detail); - } - return response.json(); -} - -function parseErrorDetail(error) { - try { - return JSON.parse(error.message); - } catch (_error) { - return null; - } -} - -function setBusy(button, busyLabel) { - const previous = button.textContent; - button.disabled = true; - button.textContent = busyLabel; - return () => { - button.disabled = false; - button.textContent = previous; - }; -} - -function clearNode(node, emptyText = "") { - node.innerHTML = ""; - if (emptyText) { - node.classList.add("empty-state"); - node.textContent = emptyText; - } else { - node.classList.remove("empty-state"); - } -} - -function createListCard({ title, score = "", body = "", active = false }) { - const card = document.createElement("article"); - card.className = "list-card"; - if (active) { - card.classList.add("is-active"); - } - card.innerHTML = ` -
-

${title}

- ${score} -
-

${body}

- `; - return card; -} - -function latestAsyncJob(jobType) { - return (appState.opsAsyncJobs || []).find((item) => item.job_type === jobType) || null; -} - -function formatTimestamp(value) { - if (!value) return "未知时间"; - try { - return new Date(value).toLocaleString("zh-CN"); - } catch (error) { - return value; - } -} - -function downloadJsonFile(filename, payload) { - const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = filename; - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - URL.revokeObjectURL(url); -} - -function getActiveDraftWorldpack() { - return appState.activeDraftDetail?.worldpack_json || appState.activeDraftDetail?.worldpack || null; -} - -function getActiveRevisionHistory() { - return appState.activeDraftDetail?.revision_history || getActiveDraftWorldpack()?.metadata?.revision_history || []; -} - -function getLatestDiffSummary() { - return appState.activeDraftDetail?.latest_diff_summary || getActiveDraftWorldpack()?.metadata?.latest_diff_summary || {}; -} - -function getDiffDrilldown() { - return appState.activeDraftDetail?.diff_drilldown || {}; -} - -function getSimulationDrilldown() { - return ( - appState.authorSimulationReport?.simulation_drilldown || - appState.activeDraftDetail?.simulation_drilldown || - {} - ); -} - -function formatPercent(value) { - return `${(Number(value || 0) * 100).toFixed(0)}%`; -} - -function parseIssueCodes(value) { - return String(value || "") - .split(/[\s,,]+/) - .map((item) => item.trim()) - .filter(Boolean); -} - -function parseTagList(value) { - return String(value || "") - .split(/[\s,,]+/) - .map((item) => item.trim()) - .filter(Boolean); -} - -function currentTierCatalog() { - return ( - appState.readerSubscription?.tiers || - appState.opsSubscriptionAudit?.tiers || - [] - ); -} - -function tierLabel(tierId) { - const tier = currentTierCatalog().find((item) => item.tier_id === tierId); - return tier?.display_name || tierId || "-"; -} - -function accessReasonLabel(reason) { - return { - trial_chapter: "试读章节", - grace_window: "宽限章节", - continue_requires_entitlement: "需要更高权限", - subscriber_active: "会员已生效", - subscription_active: "会员已生效", - subscription_required: "需要 Creator/Studio 会员", - world_pass_active: "世界已解锁", - credits_balance: "Story Credits 可用", - credits_consumed: "已消耗 Story Credits", - credits_exhausted: "Story Credits 已耗尽", - studio_credits_balance: "Studio Credits 可用", - studio_credits_exhausted: "Studio Credits 已耗尽", - author_tier_required: "当前会员档位不支持创作", - entitlement_expired: "权益已过期", - missing_reader: "缺少 reader_id", - missing_account: "缺少 account_id", - }[reason] || reason || "-"; -} - -function worldUnlockLabel(paywall) { - if (!appState.worldId) return "-"; - if (!paywall) return "试读中"; - if (paywall.entitlement_type === "subscriber") return `${paywall.tier_id || "会员"} 已解锁`; - if (paywall.entitlement_type === "world_pass") return "world pass 已解锁"; - if (paywall.entitlement_type === "credits") return paywall.required ? "需消耗 Story Credits" : "Story Credits 可继续"; - if (!paywall.required) return "试读中"; - return "未解锁"; -} - -function gatingStatusLabel(access) { - if (!access) return "-"; - if (access.allowed === true || access.required === false) { - return "可用"; - } - return `受限 · ${accessReasonLabel(access.reason)}`; -} - -function gatingHint(access) { - if (!access) return "-"; - const tierText = access.required_display_name || tierLabel(access.required_tier); - const balanceText = access.balance !== null && access.balance !== undefined ? Number(access.balance).toFixed(0) : "-"; - const unitsText = access.required_units !== null && access.required_units !== undefined ? ` · 需要 ${Number(access.required_units).toFixed(0)}` : ""; - return `${gatingStatusLabel(access)} · ${tierText || "-"} · ${access.wallet_type || "-"} · 余额 ${balanceText}${unitsText}`; -} - -function alertAuthorGating(errorDetail, actionLabel) { - alert(`当前不能${actionLabel}:${accessReasonLabel(errorDetail.reason)}。需要 ${errorDetail.required_display_name || tierLabel(errorDetail.required_tier)},当前 ${errorDetail.wallet_type || "-"} 余额 ${Number(errorDetail.balance || 0).toFixed(0)}${errorDetail.required_units !== undefined ? ` / 需要 ${Number(errorDetail.required_units).toFixed(0)}` : ""}。`); -} - -function authorStageLabel(stage) { - return { - brief: "写 Brief", - draft_created: "创建 Draft", - validated: "校验通过", - simulated: "完成 Simulation", - revised_after_simulation: "修改后待重跑", - ready_to_submit: "准备送审", - submitted: "已提交审核", - }[stage] || stage || "-"; -} - -function focusAuthorPanel(panelKey) { - const mapping = { - workflow: els.authorWorkflow, - draft_detail: els.authorDraftDetail, - validation: els.authorValidationReport, - simulation: els.authorSimulationReport, - diff: els.authorAssetDiff, - compare: els.authorCompare, - collaboration: els.authorCollaboration, - version_history: els.authorVersionHistory, - brief: els.authorCorePremise, - }; - const target = mapping[panelKey]; - const node = target?.closest(".panel") || target; - node?.scrollIntoView({ behavior: "smooth", block: "start" }); -} - -function prefillAuthorCommentAnchor(anchorType, anchorKey) { - if (els.authorCommentAnchorType) { - els.authorCommentAnchorType.value = anchorType || "draft"; - } - if (els.authorCommentAnchorKey) { - els.authorCommentAnchorKey.value = anchorKey || ""; - } - focusAuthorPanel("collaboration"); -} - -function activeAuthorReviewerId() { - return ( - els.authorInboxReviewerId?.value.trim() || - (appState.authorAuthSession?.identity?.actor_role === "reviewer" ? appState.authorAuthSession.identity.actor_id : "") || - els.authorApprovalReviewer?.value.trim() || - els.authorAccountId?.value.trim() || - "ops_author_reviewer" - ); -} - -function activeAuthorActorId(options = {}) { - if (options.preferReviewer) { - return activeAuthorReviewerId() || els.authorAccountId?.value.trim() || "ops_author_reviewer"; - } - return appState.authorAuthSession?.identity?.actor_id || els.authorAccountId?.value.trim() || activeAuthorReviewerId() || "web_author"; -} - -function activeAuthorActorRole(actorId = activeAuthorActorId()) { - if (appState.authorAuthSession?.identity?.actor_role) { - return appState.authorAuthSession.identity.actor_role; - } - const draftAuthorId = appState.activeDraftDetail?.worldpack?.manifest?.author_id || ""; - return actorId && draftAuthorId && actorId === draftAuthorId ? "author" : "reviewer"; -} - -function currentAuthorInboxFilters() { - return { - reviewerId: activeAuthorReviewerId(), - statusFilter: els.authorInboxStatusFilter?.value || "all", - worldVersionId: els.authorInboxWorldVersionFilter?.value.trim() || "", - notificationType: els.authorInboxNotificationTypeFilter?.value || "", - blockingOnly: Boolean(els.authorInboxBlockingOnly?.checked), - query: (els.authorInboxSearch?.value || "").trim(), - }; -} - -function authorCollaborationHeaders(options = {}) { - const actorId = options.actorId || (options.preferReviewer ? activeAuthorReviewerId() : activeAuthorActorId(options)); - const actorRole = options.actorRole || (options.preferReviewer ? "reviewer" : activeAuthorActorRole(actorId)); - const accountId = els.authorAccountId?.value.trim() || ""; - return { - "X-NarrativeOS-Actor-Id": actorId, - "X-NarrativeOS-Actor-Role": actorRole, - ...(accountId ? { "X-NarrativeOS-Account-Id": accountId } : {}), - }; -} - -async function selectAuthorThread(threadId, worldVersionId = "") { - appState.selectedAuthorThreadId = threadId || null; - if (worldVersionId && worldVersionId !== appState.activeDraftVersionId) { - appState.activeDraftVersionId = worldVersionId; - await refreshAuthorSurface(); - return; - } - renderAuthorReports(); - focusAuthorPanel("collaboration"); -} - -function mergeAuthorReviewerInbox(existing, nextPayload) { - if (!existing) { - return nextPayload; - } - const mergedNotifications = [...(existing.notifications || []), ...(nextPayload.notifications || [])]; - const seen = new Set(); - const uniqueNotifications = []; - for (const item of mergedNotifications) { - if (!item?.notification_id || seen.has(item.notification_id)) continue; - seen.add(item.notification_id); - uniqueNotifications.push(item); - } - return { - ...existing, - filters: nextPayload.filters || existing.filters, - has_more: nextPayload.has_more, - next_cursor: nextPayload.next_cursor, - returned_count: uniqueNotifications.length, - notifications: uniqueNotifications, - unread_notifications: uniqueNotifications.filter((item) => item.status === "unread"), - }; -} - -function syncAuthorNotificationPreferenceInputs() { - const targetType = els.authorNotificationPrefType?.value || "thread_assigned"; - const preferences = appState.authorNotificationPreferences?.preferences || []; - const selected = preferences.find((item) => item.notification_type === targetType); - if (els.authorNotificationPrefInApp) { - els.authorNotificationPrefInApp.checked = selected ? Boolean(selected.in_app_enabled) : true; - } - if (els.authorNotificationPrefAsync) { - els.authorNotificationPrefAsync.checked = selected ? Boolean(selected.async_mirror_enabled) : true; - } - if (els.authorNotificationPrefSink) { - els.authorNotificationPrefSink.value = selected?.async_sink_name || "default"; - } - if (els.authorNotificationPrefTarget) { - els.authorNotificationPrefTarget.value = selected?.delivery_target || ""; - } -} - -function persistAuthorAuthSession() { - if (typeof window === "undefined") return; - if (appState.authorAuthSession?.accessToken) { - window.localStorage.setItem("narrativeos_author_auth", JSON.stringify(appState.authorAuthSession)); - } else { - window.localStorage.removeItem("narrativeos_author_auth"); - } -} - -function restoreAuthorAuthSession() { - if (typeof window === "undefined") return; - try { - const raw = window.localStorage.getItem("narrativeos_author_auth"); - appState.authorAuthSession = raw ? JSON.parse(raw) : null; - } catch (_error) { - appState.authorAuthSession = null; - } -} - -function renderAuthorAuthStatus() { - clearNode(els.authorAuthStatus); - const session = appState.authorAuthSession; - if (!session?.identity) { - clearNode(els.authorAuthStatus, "这里会显示当前 bearer token 会话与身份信息。"); - return; - } - els.authorAuthStatus.appendChild( - createListCard({ - title: `Signed In · ${session.identity.actor_id || "-"}`, - score: session.identity.actor_role || "-", - body: - `account ${session.identity.account_id || "-"}\n` + - `display ${session.identity.display_name || "-"}\n` + - `token ${(session.accessToken || "").slice(0, 18)}...\n` + - `expires ${session.expiresAt || "-"}` - }) - ); -} - -async function refreshAuthorReviewerInbox(options = {}) { - const { reviewerId, statusFilter, worldVersionId, notificationType, blockingOnly, query: searchQuery } = currentAuthorInboxFilters(); - if (!reviewerId) { - appState.authorReviewerInbox = null; - appState.authorReviewerInboxNextCursor = null; - appState.authorReviewerInboxHasMore = false; - return; - } - const params = new URLSearchParams(); - params.set("reviewer_id", reviewerId); - params.set("limit", "12"); - params.set("status_filter", statusFilter); - if (worldVersionId) { - params.set("world_version_id", worldVersionId); - } - if (notificationType) { - params.set("notification_type", notificationType); - } - if (blockingOnly) { - params.set("blocking_only", "true"); - } - if (searchQuery) { - params.set("q", searchQuery); - } - if (options.cursor) { - params.set("cursor", options.cursor); - } - const payload = await api(`/v1/author/reviewer-inbox?${params.toString()}`, { - headers: authorCollaborationHeaders({ preferReviewer: true }), - }); - appState.authorReviewerInbox = options.append ? mergeAuthorReviewerInbox(appState.authorReviewerInbox, payload) : payload; - appState.authorReviewerInboxNextCursor = payload.next_cursor || null; - appState.authorReviewerInboxHasMore = Boolean(payload.has_more); - appState.authorReviewerInboxSearch = searchQuery; -} - -async function updateAuthorThreadStatusInline(threadId, status, options = {}) { - const body = options.body || ""; - const actorId = options.actorId || activeAuthorActorId(); - await api(`/v1/author/comments/${encodeURIComponent(threadId)}/status`, { - method: "POST", - headers: authorCollaborationHeaders({ - actorId, - actorRole: options.actorRole || activeAuthorActorRole(actorId), - }), - body: JSON.stringify({ - status, - assignee_id: options.assigneeId === undefined ? undefined : options.assigneeId, - actor_id: actorId, - actor_role: options.actorRole || activeAuthorActorRole(actorId), - body: body || undefined, - }), - }); - await refreshAuthorSurface(); - focusAuthorPanel("collaboration"); -} - -async function updateAuthorNotificationStatus(notificationId, status) { - await api(`/v1/author/notifications/${encodeURIComponent(notificationId)}/status`, { - method: "POST", - headers: authorCollaborationHeaders({ preferReviewer: true }), - body: JSON.stringify({ - status, - recipient_id: activeAuthorReviewerId(), - limit: 12, - }), - }); - await refreshAuthorSurface(); - focusAuthorPanel("collaboration"); -} - -async function bulkUpdateAuthorNotificationStatus(status) { - const notificationIds = appState.authorReviewerInboxVisibleNotificationIds || []; - if (!notificationIds.length) { - alert("当前没有可批量处理的 notifications。"); - return; - } - await api("/v1/author/notifications/bulk-status", { - method: "POST", - headers: authorCollaborationHeaders({ preferReviewer: true }), - body: JSON.stringify({ - notification_ids: notificationIds, - recipient_id: activeAuthorReviewerId(), - status, - limit: 12, - }), - }); - await refreshAuthorSurface(); - focusAuthorPanel("collaboration"); -} - -async function decideAuthorApprovalForWorld(worldVersionId, status, reviewerId, reason) { - await api(`/v1/author/drafts/${encodeURIComponent(worldVersionId)}/approval/decision`, { - method: "POST", - headers: authorCollaborationHeaders({ actorId: reviewerId || activeAuthorReviewerId(), actorRole: "reviewer" }), - body: JSON.stringify({ - reviewer_id: reviewerId || activeAuthorReviewerId(), - status, - reason: reason || (status === "approved" ? "Reviewer inbox 快速批准。" : "Reviewer inbox 要求修改。"), - }), - }); - await refreshAuthorSurface(); - focusAuthorPanel("collaboration"); -} - -async function addAuthorThreadWatcher(threadId, watcherId = "") { - const actorId = activeAuthorActorId(); - await api(`/v1/author/comments/${encodeURIComponent(threadId)}/watchers`, { - method: "POST", - headers: authorCollaborationHeaders({ actorId, actorRole: activeAuthorActorRole(actorId) }), - body: JSON.stringify({ - actor_id: actorId, - watcher_id: watcherId || actorId, - }), - }); - await refreshAuthorSurface(); - focusAuthorPanel("collaboration"); -} - -async function removeAuthorThreadWatcher(threadId, watcherId) { - const actorId = activeAuthorActorId(); - await api(`/v1/author/comments/${encodeURIComponent(threadId)}/watchers/${encodeURIComponent(watcherId)}/remove`, { - method: "POST", - headers: authorCollaborationHeaders({ actorId, actorRole: activeAuthorActorRole(actorId) }), - body: JSON.stringify({ - actor_id: actorId, - watcher_id: watcherId, - }), - }); - await refreshAuthorSurface(); - focusAuthorPanel("collaboration"); -} - -async function replyToSelectedAuthorThread(threadId) { - const body = (appState.authorInlineReplyDraft || "").trim(); - if (!body) { - alert("先写回复内容。"); - return; - } - const actorId = activeAuthorActorId(); - await api(`/v1/author/comments/${encodeURIComponent(threadId)}/reply`, { - method: "POST", - headers: authorCollaborationHeaders({ actorId, actorRole: activeAuthorActorRole(actorId) }), - body: JSON.stringify({ - actor_id: actorId, - actor_role: activeAuthorActorRole(actorId), - body, - }), - }); - appState.authorInlineReplyDraft = ""; - await refreshAuthorSurface(); - focusAuthorPanel("collaboration"); -} - -async function addAuthorDraftWatcher() { - if (!appState.activeDraftVersionId) { - alert("先选择一个 draft。"); - return; - } - const watcherId = (els.authorDraftWatcherId?.value || "").trim() || activeAuthorActorId(); - const actorId = activeAuthorActorId(); - await api(`/v1/author/drafts/${encodeURIComponent(appState.activeDraftVersionId)}/watchers`, { - method: "POST", - headers: authorCollaborationHeaders({ actorId, actorRole: activeAuthorActorRole(actorId) }), - body: JSON.stringify({ - actor_id: actorId, - watcher_id: watcherId, - }), - }); - await refreshAuthorSurface(); - focusAuthorPanel("collaboration"); -} - -async function removeAuthorDraftWatcher() { - if (!appState.activeDraftVersionId) { - alert("先选择一个 draft。"); - return; - } - const watcherId = (els.authorDraftWatcherId?.value || "").trim(); - if (!watcherId) { - alert("先填写 draft watcher id。"); - return; - } - const actorId = activeAuthorActorId(); - await api(`/v1/author/drafts/${encodeURIComponent(appState.activeDraftVersionId)}/watchers/${encodeURIComponent(watcherId)}/remove`, { - method: "POST", - headers: authorCollaborationHeaders({ actorId, actorRole: activeAuthorActorRole(actorId) }), - body: JSON.stringify({ - actor_id: actorId, - watcher_id: watcherId, - }), - }); - await refreshAuthorSurface(); - focusAuthorPanel("collaboration"); -} - -async function refreshAuthorNotificationPreferences() { - const actorId = activeAuthorActorId(); - appState.authorNotificationPreferences = await api( - `/v1/author/notification-preferences?actor_id=${encodeURIComponent(actorId)}`, - { - headers: authorCollaborationHeaders({ actorId, actorRole: activeAuthorActorRole(actorId) }), - } - ); - syncAuthorNotificationPreferenceInputs(); -} - -async function saveAuthorNotificationPreference() { - const actorId = activeAuthorActorId(); - await api("/v1/author/notification-preferences", { - method: "POST", - headers: authorCollaborationHeaders({ actorId, actorRole: activeAuthorActorRole(actorId) }), - body: JSON.stringify({ - actor_id: actorId, - notification_type: els.authorNotificationPrefType?.value || "thread_assigned", - in_app_enabled: Boolean(els.authorNotificationPrefInApp?.checked), - async_mirror_enabled: Boolean(els.authorNotificationPrefAsync?.checked), - async_sink_name: els.authorNotificationPrefSink?.value || "default", - delivery_target: (els.authorNotificationPrefTarget?.value || "").trim() || null, - }), - }); - await refreshAuthorNotificationPreferences(); - renderAuthorReports(); -} - -async function registerAuthorAuthIdentity() { - const actorId = (els.authorAuthActorId?.value || "").trim() || activeAuthorActorId(); - const password = (els.authorAuthPassword?.value || "").trim(); - if (!actorId || !password) { - alert("请先填写 actor id 和 password。"); - return; - } - await api("/v1/auth/register", { - method: "POST", - body: JSON.stringify({ - actor_id: actorId, - actor_role: els.authorAuthRole?.value || "author", - password, - account_id: els.authorAccountId?.value.trim() || actorId, - display_name: (els.authorAuthDisplayName?.value || "").trim() || null, - }), - }); - await loginAuthorAuthIdentity(); -} - -async function loginAuthorAuthIdentity() { - const actorId = (els.authorAuthActorId?.value || "").trim() || activeAuthorActorId(); - const password = (els.authorAuthPassword?.value || "").trim(); - if (!actorId || !password) { - alert("请先填写 actor id 和 password。"); - return; - } - const payload = await api("/v1/auth/login", { - method: "POST", - body: JSON.stringify({ - actor_id: actorId, - password, - }), - }); - appState.authorAuthSession = { - accessToken: payload.token?.access_token, - expiresAt: payload.token?.expires_at, - identity: payload.identity, - tokenType: payload.token?.token_type || "bearer", - }; - persistAuthorAuthSession(); - if (els.authorAccountId && payload.identity?.account_id) { - els.authorAccountId.value = payload.identity.account_id; - } - renderAuthorAuthStatus(); - await refreshAuthorSurface(); -} - -async function hydrateAuthorAuthSession() { - if (!appState.authorAuthSession?.accessToken) { - renderAuthorAuthStatus(); - return; - } - try { - const payload = await api("/v1/auth/me"); - appState.authorAuthSession = { - ...appState.authorAuthSession, - identity: payload.identity, - expiresAt: payload.identity?.expires_at || appState.authorAuthSession.expiresAt, - }; - persistAuthorAuthSession(); - } catch (error) { - appState.authorAuthSession = null; - persistAuthorAuthSession(); - } - if (els.authorAccountId && appState.authorAuthSession?.identity?.account_id) { - els.authorAccountId.value = appState.authorAuthSession.identity.account_id; - } - renderAuthorAuthStatus(); -} - -async function logoutAuthorAuthIdentity() { - if (!appState.authorAuthSession?.accessToken) { - appState.authorAuthSession = null; - persistAuthorAuthSession(); - renderAuthorAuthStatus(); - return; - } - try { - await api("/v1/auth/logout", { method: "POST" }); - } catch (_error) { - // Even if logout fails remotely, clear local session for safety. - } - appState.authorAuthSession = null; - persistAuthorAuthSession(); - renderAuthorAuthStatus(); -} - -async function validateDraftVersion(worldVersionId) { - const detail = await api(`/v1/author/drafts/${worldVersionId}`); - const report = await api("/v1/author/drafts/validate", { - method: "POST", - body: JSON.stringify({ - worldpack: detail.worldpack, - account_id: els.authorAccountId?.value.trim() || "web_author", - }), - }); - appState.activeDraftVersionId = worldVersionId; - appState.activeDraftDetail = detail; - appState.selectedAuthorRevisionIndex = null; - appState.authorValidationReport = report; - appState.authorWorkflowSummary = null; - await refreshAuthorSurface(); - focusAuthorPanel("validation"); - return report; -} - -async function simulateDraftVersion(worldVersionId) { - appState.activeDraftDetail = await api(`/v1/author/drafts/${worldVersionId}`); - appState.authorPreviousSimulationReport = appState.authorSimulationReport; - const report = await api(`/v1/author/drafts/${worldVersionId}/simulate`, { method: "POST" }); - appState.activeDraftVersionId = worldVersionId; - appState.authorSimulationReport = report; - appState.activeDraftDetail = await api(`/v1/author/drafts/${worldVersionId}`); - appState.selectedAuthorRevisionIndex = null; - appState.authorWorkflowSummary = null; - await refreshAuthorSurface(); - await refreshOpsSurface(); - focusAuthorPanel("simulation"); - return report; -} - -async function submitDraftVersion(worldVersionId) { - appState.activeDraftDetail = await api(`/v1/author/drafts/${worldVersionId}`); - const report = await api( - `/v1/author/drafts/${worldVersionId}/submit?account_id=${encodeURIComponent(els.authorAccountId?.value.trim() || "web_author")}`, - { method: "POST" } - ); - appState.activeDraftVersionId = worldVersionId; - appState.authorValidationReport = report; - appState.selectedAuthorRevisionIndex = null; - appState.authorWorkflowSummary = null; - await refreshAuthorSurface(); - await refreshOpsSurface(); - focusAuthorPanel("version_history"); - return report; -} - -async function createAuthorCommentThread() { - if (!appState.activeDraftVersionId) { - alert("先选择一个 draft。"); - return; - } - const body = els.authorCommentBody?.value.trim() || ""; - if (!body) { - alert("先写评论内容。"); - return; - } - const actorId = activeAuthorActorId(); - const created = await api(`/v1/author/drafts/${appState.activeDraftVersionId}/comments`, { - method: "POST", - headers: authorCollaborationHeaders({ actorId, actorRole: activeAuthorActorRole(actorId) }), - body: JSON.stringify({ - revision_id: getActiveRevisionHistory().slice(-1)[0]?.revision_id || null, - anchor_type: els.authorCommentAnchorType?.value || "draft", - anchor_key: els.authorCommentAnchorKey?.value.trim() || appState.activeDraftVersionId, - severity: els.authorCommentSeverity?.value || "normal", - assignee_id: els.authorCommentAssignee?.value.trim() || null, - actor_id: actorId, - actor_role: activeAuthorActorRole(actorId), - body, - }), - }); - appState.selectedAuthorThreadId = created.thread?.thread_id || appState.selectedAuthorThreadId; - if (els.authorCommentBody) els.authorCommentBody.value = ""; - await refreshAuthorSurface(); - focusAuthorPanel("collaboration"); -} - -async function requestAuthorApproval() { - if (!appState.activeDraftVersionId) { - alert("先选择一个 draft。"); - return; - } - const reviewerId = els.authorApprovalReviewer?.value.trim() || activeAuthorReviewerId(); - const actorId = activeAuthorActorId(); - await api(`/v1/author/drafts/${appState.activeDraftVersionId}/approval/request`, { - method: "POST", - headers: authorCollaborationHeaders({ actorId, actorRole: activeAuthorActorRole(actorId) }), - body: JSON.stringify({ - revision_id: getActiveRevisionHistory().slice(-1)[0]?.revision_id || null, - reviewer_id: reviewerId, - reason: els.authorApprovalReason?.value.trim() || "请求内部审批。", - actor_id: actorId, - actor_role: activeAuthorActorRole(actorId), - }), - }); - if (els.authorInboxReviewerId && reviewerId) { - els.authorInboxReviewerId.value = reviewerId; - } - await refreshAuthorSurface(); - focusAuthorPanel("collaboration"); -} - -async function decideAuthorApproval(status) { - if (!appState.activeDraftVersionId) { - alert("先选择一个 draft。"); - return; - } - const reviewerId = els.authorApprovalReviewer?.value.trim() || activeAuthorReviewerId(); - await api(`/v1/author/drafts/${appState.activeDraftVersionId}/approval/decision`, { - method: "POST", - headers: authorCollaborationHeaders({ actorId: reviewerId, actorRole: "reviewer" }), - body: JSON.stringify({ - revision_id: getActiveRevisionHistory().slice(-1)[0]?.revision_id || null, - reviewer_id: reviewerId, - status, - reason: els.authorApprovalReason?.value.trim() || (status === "approved" ? "批准送审。" : "需要修改。"), - }), - }); - if (els.authorInboxReviewerId && reviewerId) { - els.authorInboxReviewerId.value = reviewerId; - } - await refreshAuthorSurface(); - focusAuthorPanel("collaboration"); -} - -async function runAuthorWorkflowAction(actionId) { - const draftId = appState.authorWorkflowSummary?.world_version_id || appState.activeDraftVersionId; - if (actionId === "create_from_brief") { - focusAuthorPanel("brief"); - await createDraftFromBrief(); - return; - } - if (actionId === "copy_current_world") { - await createDraftFromCurrentWorld(); - return; - } - if (actionId === "validate_draft" && draftId) { - await validateDraftVersion(draftId); - return; - } - if (actionId === "simulate_draft" && draftId) { - await simulateDraftVersion(draftId); - return; - } - if (actionId === "submit_draft" && draftId) { - await submitDraftVersion(draftId); - return; - } - if (actionId === "focus_validation") { - focusAuthorPanel("validation"); - return; - } - if (actionId === "focus_simulation") { - focusAuthorPanel("simulation"); - return; - } - if (actionId === "focus_diff" || actionId === "focus_revision") { - focusAuthorPanel("diff"); - return; - } - if (actionId === "focus_version_history") { - focusAuthorPanel("version_history"); - return; - } - if (actionId === "focus_draft_detail") { - focusAuthorPanel("draft_detail"); - return; - } -} - -function populateAuthorBriefForm(force = false) { - const payload = appState.authorBriefTemplate; - if (!payload) return; - const defaults = payload.defaults || {}; - const presets = payload.genre_presets || []; - if (!els.authorGenrePreset) return; - if (!els.authorGenrePreset.options.length) { - els.authorGenrePreset.innerHTML = presets - .map((preset) => ``) - .join(""); - } - const hasUserInput = - els.authorWorldTitle?.value || - els.authorLeadName?.value || - els.authorCorePremise?.value || - els.authorLifeTheme?.value; - if (!force && hasUserInput) return; - els.authorGenrePreset.value = defaults.genre_preset || presets[0]?.id || "urban_mystery"; - els.authorWorldTitle.value = defaults.world_title || ""; - els.authorLeadName.value = defaults.lead_name || ""; - els.authorCounterpartName.value = defaults.counterpart_name || ""; - els.authorSupportingName.value = defaults.supporting_name || ""; - els.authorLifeTheme.value = defaults.life_theme || ""; - els.authorCorePremise.value = defaults.core_premise || ""; - els.authorLocations.value = defaults.locations || ""; -} - -function applyAuthorPresetDefaults() { - const payload = appState.authorBriefTemplate; - if (!payload) return; - const selected = els.authorGenrePreset?.value; - const defaults = payload.preset_defaults?.[selected]; - if (!defaults) return; - els.authorWorldTitle.value = defaults.world_title || ""; - els.authorLeadName.value = defaults.lead_name || ""; - els.authorCounterpartName.value = defaults.counterpart_name || ""; - els.authorSupportingName.value = defaults.supporting_name || ""; - els.authorLifeTheme.value = defaults.life_theme || ""; - els.authorCorePremise.value = defaults.core_premise || ""; - els.authorLocations.value = defaults.locations || ""; -} - -function buildAuthorBriefPayload() { - return { - genre_preset: els.authorGenrePreset?.value || "urban_mystery", - world_title: els.authorWorldTitle?.value.trim() || "", - lead_name: els.authorLeadName?.value.trim() || "", - counterpart_name: els.authorCounterpartName?.value.trim() || "", - supporting_name: els.authorSupportingName?.value.trim() || "", - life_theme: els.authorLifeTheme?.value.trim() || "", - core_premise: els.authorCorePremise?.value.trim() || "", - locations: els.authorLocations?.value || "", - author_id: els.authorAccountId?.value.trim() || "web_author", - account_id: els.authorAccountId?.value.trim() || "web_author", - }; -} - -function getActiveDraftCharacters() { - return getActiveDraftWorldpack()?.characters || []; -} - -function getActiveDraftScenes() { - return getActiveDraftWorldpack()?.scene_blueprints || []; -} - -function selectedCharacterIndex() { - return Math.max(0, Number(els.authorCharacterSelect?.value || 0)); -} - -function selectedSceneIndex() { - return Math.max(0, Number(els.authorSceneSelect?.value || 0)); -} - -function renderAuthorDraftDetail() { - clearNode(els.authorDraftDetail); - const worldpack = getActiveDraftWorldpack(); - if (!worldpack || !appState.activeDraftVersionId) { - clearNode(els.authorDraftDetail, "选择一个 draft 后,这里会显示 world / manifest / capability diagnosis。"); - return; - } - const validation = appState.activeDraftDetail?.validation_report || appState.authorValidationReport || {}; - const simulation = appState.activeDraftDetail?.simulation_report || appState.authorSimulationReport || {}; - const simulationDrilldown = getSimulationDrilldown(); - const latestDiff = getLatestDiffSummary(); - const stylePack = worldpack.narrative_style_pack || {}; - const detail = document.createElement("article"); - detail.className = "list-card"; - const diagnosis = simulation.cross_pack_summary?.worlds?.find((item) => item.world_id === appState.activeDraftDetail?.world_id) || null; - detail.innerHTML = ` -
-

${worldpack.title || appState.activeDraftDetail?.world_id || appState.activeDraftVersionId}

- ${appState.activeDraftDetail?.status || "draft"} -
-

-world_id: ${appState.activeDraftDetail?.world_id || "-"}\n -world_version_id: ${appState.activeDraftVersionId}\n -manifest: ${(worldpack.manifest?.genres || []).join(" / ") || "-"} · ${worldpack.manifest?.risk_rating || "-"}\n -validation: ${validation.ok ? "ok" : "pending"} · errors ${(validation.errors || []).length || 0} · warnings ${(validation.warnings || []).length || 0}\n -simulation: ${simulation.latest_decision || "-"} · pass ${formatPercent(simulation.evaluation_summary?.pass_rate)} · rewrite ${formatPercent(simulation.evaluation_summary?.rewrite_rate)} · block ${formatPercent(simulation.evaluation_summary?.block_rate)}\n -simulation drill-down: completion ${simulationDrilldown.completion_ratio !== undefined ? Number(simulationDrilldown.completion_ratio).toFixed(3) : "-"} · stop ${simulationDrilldown.stop_reason || "-"} · chapters ${simulationDrilldown.completed_chapters ?? simulation.completed_chapters ?? 0}\n -style / pacing / hook: tone ${(stylePack.tonal_lexicon || []).slice(0, 3).join(" / ") || "-"} · hook ${(stylePack.hook_templates || [])[0] || "-"} · turns ${worldpack.dialogue_realism_policy?.min_turns || 2}-${worldpack.dialogue_realism_policy?.max_turns || 3}\n -diagnosis: ${diagnosis?.issue_summary?.dominant_issue || "-"}\n -weakest: ${(diagnosis?.issue_summary?.weakest_dimensions || []).map((item) => `${item.name}=${Number(item.value || 0).toFixed(3)}`).join(" / ") || "-"}\n -recommended: ${diagnosis?.issue_summary?.recommended_target || "-"}\n -latest diff: ${latestDiff.summary_text || "-"} -

- `; - els.authorDraftDetail.appendChild(detail); -} - -function renderCharacterEditor() { - const characters = getActiveDraftCharacters(); - if (!els.authorCharacterSelect) return; - if (!characters.length) { - els.authorCharacterSelect.innerHTML = ""; - els.authorCharacterName.value = ""; - els.authorCharacterRole.value = ""; - els.authorCharacterLifeTheme.value = ""; - els.authorCharacterCoreWound.value = ""; - els.authorCharacterPublicSelf.value = ""; - els.authorCharacterShadowDesire.value = ""; - els.authorCharacterVows.value = ""; - return; - } - els.authorCharacterSelect.innerHTML = characters - .map((character, index) => ``) - .join(""); - const character = characters[Math.min(selectedCharacterIndex(), characters.length - 1)]; - els.authorCharacterSelect.value = String(Math.min(selectedCharacterIndex(), characters.length - 1)); - els.authorCharacterName.value = character.display_name || ""; - els.authorCharacterRole.value = character.role || ""; - els.authorCharacterLifeTheme.value = character.destiny_contract?.life_theme || ""; - els.authorCharacterCoreWound.value = character.wound_profile?.core_wound || ""; - els.authorCharacterPublicSelf.value = character.wound_profile?.public_self || ""; - els.authorCharacterShadowDesire.value = character.wound_profile?.shadow_desire || ""; - els.authorCharacterVows.value = (character.vow_profile?.vows || []).join("\n"); -} - -function renderSceneEditor() { - const scenes = getActiveDraftScenes(); - if (!els.authorSceneSelect) return; - if (!scenes.length) { - els.authorSceneSelect.innerHTML = ""; - els.authorSceneId.value = ""; - els.authorSceneFunction.value = ""; - els.authorSceneRequiredRoles.value = ""; - els.authorSceneBeats.value = ""; - return; - } - els.authorSceneSelect.innerHTML = scenes - .map((scene, index) => ``) - .join(""); - const scene = scenes[Math.min(selectedSceneIndex(), scenes.length - 1)]; - els.authorSceneSelect.value = String(Math.min(selectedSceneIndex(), scenes.length - 1)); - els.authorSceneId.value = scene.scene_id || ""; - els.authorSceneFunction.value = scene.scene_function || ""; - els.authorSceneRequiredRoles.value = (scene.required_roles || []).join("\n"); - els.authorSceneBeats.value = (scene.beats_template || []).join("\n"); -} - -function parseMultilineList(value) { - return String(value || "") - .split("\n") - .map((item) => item.trim()) - .filter(Boolean); -} - -function formatMultilineList(values) { - return (values || []).join("\n"); -} - -function parseLabelMap(value) { - const result = {}; - for (const rawLine of String(value || "").split("\n")) { - const line = rawLine.trim(); - if (!line) continue; - const separatorIndex = line.includes(":") ? line.indexOf(":") : line.indexOf("="); - if (separatorIndex <= 0) continue; - const key = line.slice(0, separatorIndex).trim(); - const label = line.slice(separatorIndex + 1).trim(); - if (key && label) { - result[key] = label; - } - } - return result; -} - -function formatLabelMap(value) { - return Object.entries(value || {}) - .map(([key, label]) => `${key}: ${label}`) - .join("\n"); -} - -function parseSceneHooks(value) { - const hooks = {}; - for (const rawLine of String(value || "").split("\n")) { - const line = rawLine.trim(); - if (!line) continue; - const separatorIndex = line.includes(":") ? line.indexOf(":") : line.indexOf("="); - if (separatorIndex <= 0) continue; - const sceneFunction = line.slice(0, separatorIndex).trim(); - const hook = line.slice(separatorIndex + 1).trim(); - if (!sceneFunction || !hook) continue; - hooks[sceneFunction] = hooks[sceneFunction] || []; - hooks[sceneFunction].push(hook); - } - return hooks; -} - -function formatSceneHooks(value) { - const lines = []; - Object.entries(value || {}).forEach(([sceneFunction, hooks]) => { - (hooks || []).forEach((hook) => { - if (hook) { - lines.push(`${sceneFunction}: ${hook}`); - } - }); - }); - return lines.join("\n"); -} - -function renderStylePacingHookControls() { - const worldpack = getActiveDraftWorldpack() || {}; - const stylePack = worldpack.narrative_style_pack || {}; - const dialoguePolicy = worldpack.dialogue_realism_policy || stylePack.dialogue || {}; - const sceneContracts = worldpack.scene_realization_contracts || {}; - const defaultSceneContract = Object.values(sceneContracts)[0] || stylePack.scene_realization || {}; - const thematicLabels = stylePack.thematic_axis_labels || {}; - - if (els.authorStyleLexicon) { - els.authorStyleLexicon.value = formatMultilineList(stylePack.tonal_lexicon || []); - } - if (els.authorThemeLabels) { - els.authorThemeLabels.value = formatLabelMap(thematicLabels); - } - if (els.authorHookTemplates) { - els.authorHookTemplates.value = formatMultilineList(stylePack.hook_templates || []); - } - if (els.authorPacingRequireTurnTaking) { - els.authorPacingRequireTurnTaking.checked = Boolean(dialoguePolicy.require_turn_taking ?? true); - } - if (els.authorPacingRequireCounterReaction) { - els.authorPacingRequireCounterReaction.checked = Boolean(dialoguePolicy.require_counter_reaction ?? true); - } - if (els.authorPacingMinTurns) { - els.authorPacingMinTurns.value = String(Number(dialoguePolicy.min_turns || 2)); - } - if (els.authorPacingMaxTurns) { - els.authorPacingMaxTurns.value = String(Number(dialoguePolicy.max_turns || 3)); - } - if (els.authorPacingMinimumExchanges) { - els.authorPacingMinimumExchanges.value = String(Number(dialoguePolicy.minimum_exchanges || 1)); - } - if (els.authorPacingTurnPattern) { - els.authorPacingTurnPattern.value = formatMultilineList(dialoguePolicy.turn_pattern || ["speaker", "reaction", "reply"]); - } - if (els.authorSceneHooks) { - els.authorSceneHooks.value = formatSceneHooks(defaultSceneContract.scene_hooks || {}); - } -} - -function applyStylePacingHookControls(worldpack) { - worldpack.narrative_style_pack = worldpack.narrative_style_pack || {}; - const stylePack = worldpack.narrative_style_pack; - const thematicLabels = parseLabelMap(els.authorThemeLabels?.value || ""); - stylePack.tonal_lexicon = parseMultilineList(els.authorStyleLexicon?.value || ""); - stylePack.thematic_axis_labels = thematicLabels; - stylePack.hook_templates = parseMultilineList(els.authorHookTemplates?.value || ""); - stylePack.tag_labels = { - ...(stylePack.tag_labels || {}), - ...thematicLabels, - }; - - worldpack.dialogue_realism_policy = worldpack.dialogue_realism_policy || {}; - const minTurns = Math.max(1, Number(els.authorPacingMinTurns?.value || 2)); - const maxTurns = Math.max(minTurns, Number(els.authorPacingMaxTurns?.value || 3)); - worldpack.dialogue_realism_policy.require_turn_taking = Boolean(els.authorPacingRequireTurnTaking?.checked); - worldpack.dialogue_realism_policy.require_counter_reaction = Boolean(els.authorPacingRequireCounterReaction?.checked); - worldpack.dialogue_realism_policy.min_turns = minTurns; - worldpack.dialogue_realism_policy.max_turns = maxTurns; - worldpack.dialogue_realism_policy.minimum_exchanges = Math.max(1, Number(els.authorPacingMinimumExchanges?.value || 1)); - worldpack.dialogue_realism_policy.turn_pattern = parseMultilineList(els.authorPacingTurnPattern?.value || "") || ["speaker", "reaction", "reply"]; - - const defaultContractKey = Object.keys(worldpack.scene_realization_contracts || {})[0] || "default"; - worldpack.scene_realization_contracts = worldpack.scene_realization_contracts || {}; - worldpack.scene_realization_contracts[defaultContractKey] = { - ...(worldpack.scene_realization_contracts[defaultContractKey] || {}), - scene_hooks: parseSceneHooks(els.authorSceneHooks?.value || ""), - }; -} - -function buildSimulationDiffSummary(previousReport, currentReport) { - if (!previousReport || !currentReport) return ""; - const previous = previousReport.evaluation_summary || {}; - const current = currentReport.evaluation_summary || {}; - const parts = []; - for (const key of ["pass_rate", "rewrite_rate", "block_rate"]) { - const delta = Number(current[key] || 0) - Number(previous[key] || 0); - if (delta !== 0) { - parts.push(`${key}: ${delta >= 0 ? "+" : ""}${delta.toFixed(3)}`); - } - } - return parts.join("\n"); -} - -function renderAuthorRevisionPanels() { - clearNode(els.authorAssetDiff); - clearNode(els.authorVersionHistory); - const revisions = getActiveRevisionHistory(); - const diffDrilldown = getDiffDrilldown(); - const revisionEntries = diffDrilldown.revisions || []; - const latestDiff = getLatestDiffSummary(); - if (!revisions.length) { - clearNode(els.authorAssetDiff, "保存角色、场景或能力配置后,这里会显示结构化 diff 摘要。"); - clearNode(els.authorVersionHistory, "这里会显示最近几次 revision 与对应的修改来源。"); - return; - } - - const selectedIndex = Math.max(0, Math.min(appState.selectedAuthorRevisionIndex ?? revisions.length - 1, revisions.length - 1)); - appState.selectedAuthorRevisionIndex = selectedIndex; - const selectedRevision = revisions[selectedIndex]; - const selectedEntry = revisionEntries[selectedIndex] || {}; - const previousEntry = selectedIndex > 0 ? revisionEntries[selectedIndex - 1] || {} : {}; - const diffPayload = selectedEntry.diff_summary || (selectedIndex === revisions.length - 1 ? latestDiff : { - changed_sections: selectedRevision.changed_sections || [], - summary_text: selectedRevision.summary || "", - character_changes: [], - scene_changes: [], - capability_changes: [], - }); - - els.authorAssetDiff.appendChild( - createListCard({ - title: selectedRevision.label || "最近一次修改", - score: selectedRevision.source || "-", - body: - `summary: ${diffPayload.summary_text || selectedRevision.summary || "-"}\n` + - `compare: ${previousEntry.snapshot_summary || "初始版本"} -> ${selectedEntry.snapshot_summary || "-"}\n` + - `changed_sections: ${(diffPayload.changed_sections || []).join(" / ") || "-"}\n\n` + - `section counts: sections ${diffDrilldown.section_change_counts?.sections ?? 0} · characters ${diffDrilldown.section_change_counts?.characters ?? 0} · scenes ${diffDrilldown.section_change_counts?.scenes ?? 0} · capabilities ${diffDrilldown.section_change_counts?.capabilities ?? 0}\n` + - `simulation freshness: ${diffDrilldown.simulation_freshness?.status || "-"}\n` + - `recommended next: ${(diffDrilldown.recommended_next_actions || []).join(" / ") || "-"}\n\n` + - `${(diffPayload.character_changes || []).length ? `角色改动:\n${diffPayload.character_changes.map((item) => `${item.character_id}: ${(item.changed_fields || []).join(", ")}`).join("\n")}` : "角色改动: -"}\n\n` + - `${(diffPayload.scene_changes || []).length ? `场景改动:\n${diffPayload.scene_changes.map((item) => `${item.scene_id}: ${(item.changed_fields || []).join(", ")}`).join("\n")}` : "场景改动: -"}\n\n` + - `${(diffPayload.capability_changes || []).length ? `能力改动:\n${diffPayload.capability_changes.join("\n")}` : "能力改动: -"}\n\n` + - `${selectedEntry.simulation_delta && Object.keys(selectedEntry.simulation_delta).length ? `simulation_delta:\n${Object.entries(selectedEntry.simulation_delta).map(([key, value]) => `${key}: ${typeof value === "object" ? JSON.stringify(value) : value}`).join("\n")}` : "simulation_delta: -"}` - }) - ); - const diffActions = document.createElement("div"); - diffActions.className = "composer-actions"; - const commentCurrentDiff = document.createElement("button"); - commentCurrentDiff.className = "ghost-action"; - commentCurrentDiff.textContent = "评论当前 Diff"; - commentCurrentDiff.addEventListener("click", () => { - prefillAuthorCommentAnchor("draft", selectedRevision.revision_id || appState.activeDraftVersionId || ""); - }); - diffActions.appendChild(commentCurrentDiff); - els.authorAssetDiff.appendChild(diffActions); - - revisions.slice().reverse().forEach((revision, reverseIndex) => { - const actualIndex = revisions.length - 1 - reverseIndex; - const revisionEntry = revisionEntries[actualIndex] || {}; - const card = document.createElement("article"); - card.className = "list-card"; - if (actualIndex === selectedIndex) { - card.classList.add("is-active"); - } - const snapshot = revision.worldpack_snapshot || {}; - card.innerHTML = ` -
-

${revision.label || revision.source || "revision"}

- ${revision.source || "-"} -
-

${formatTimestamp(revision.created_at)}\n${revision.summary || "-"}\n${revisionEntry.snapshot_summary || `${snapshot.title || snapshot.world_id || "-"} · 角色 ${(snapshot.characters || []).length || 0} · 场景 ${(snapshot.scene_blueprints || []).length || 0}`}\nchanged ${(revisionEntry.diff_summary?.changed_sections || revision.changed_sections || []).join(" / ") || "-"}

- `; - card.addEventListener("click", () => { - appState.selectedAuthorRevisionIndex = actualIndex; - renderAuthorRevisionPanels(); - }); - els.authorVersionHistory.appendChild(card); - }); -} - -function renderAuthorCompare() { - clearNode(els.authorCompare); - const detail = appState.activeDraftDetail || {}; - const revisionCompare = detail.revision_compare || {}; - const chapterCompare = detail.before_after_chapter_compare || {}; - if (!revisionCompare.available && !chapterCompare.available) { - clearNode(els.authorCompare, "这里会显示 revision compare 与 before-after chapter compare。"); - return; - } - if (revisionCompare.available) { - const card = createListCard({ - title: "Revision Compare", - score: `${revisionCompare.before_revision_id || "-"} -> ${revisionCompare.after_revision_id || "-"}`, - body: - `before ${revisionCompare.before_label || "-"}\nafter ${revisionCompare.after_label || "-"}\nsummary ${revisionCompare.after_summary || "-"}\nchanged ${(revisionCompare.after_diff_summary?.changed_sections || []).join(" / ") || "-"}\nsection counts before ${revisionCompare.section_counts?.before_changed_sections ?? 0} · after ${revisionCompare.section_counts?.after_changed_sections ?? 0}\nsimulation freshness ${revisionCompare.simulation_freshness?.status || "-"}\nsimulation delta ${(revisionCompare.simulation_delta && Object.keys(revisionCompare.simulation_delta).length) ? Object.entries(revisionCompare.simulation_delta).map(([key, value]) => `${key}=${typeof value === "object" ? JSON.stringify(value) : value}`).join(" / ") : "-"}` - }); - const actions = document.createElement("div"); - actions.className = "composer-actions"; - const button = document.createElement("button"); - button.className = "ghost-action"; - button.textContent = "评论当前 Diff"; - button.addEventListener("click", () => { - prefillAuthorCommentAnchor("draft", revisionCompare.after_revision_id || appState.activeDraftVersionId || ""); - }); - actions.appendChild(button); - card.appendChild(actions); - els.authorCompare.appendChild(card); - } - if (chapterCompare.available) { - const topChanged = chapterCompare.top_changed_chapters || []; - const card = createListCard({ - title: "Before / After Chapter Compare", - score: `${topChanged.length} 章`, - body: - `${topChanged.map((item) => `${item.chapter_index}. ${item.before_title || "-"} -> ${item.after_title || "-"}\n${item.before_decision || "-"} -> ${item.after_decision || "-"} · score delta ${Number(item.overall_score_delta || 0).toFixed(3)}\nissues + ${(item.issue_codes_added || []).join("/") || "-"} · - ${(item.issue_codes_removed || []).join("/") || "-"}\nsignals ${(item.signal_deltas || {}) ? Object.entries(item.signal_deltas).map(([key, value]) => `${key}=${Number(value || 0).toFixed(3)}`).join(" / ") : "-"}\nBEFORE: ${item.before_excerpt || "-"}\nAFTER: ${item.after_excerpt || "-"}`).join("\n\n") || "-"}` - }); - const actions = document.createElement("div"); - actions.className = "composer-actions"; - const firstTarget = topChanged[0]; - if (firstTarget) { - const button = document.createElement("button"); - button.className = "ghost-action"; - button.textContent = "评论首个章节对照"; - button.addEventListener("click", () => { - prefillAuthorCommentAnchor("simulation", String(firstTarget.chapter_index)); - }); - actions.appendChild(button); - } - card.appendChild(actions); - els.authorCompare.appendChild(card); - } -} - -function renderAuthorCollaboration() { - clearNode(els.authorCollaboration); - clearNode(els.authorReviewerInbox); - clearNode(els.authorNotificationPreferences); - appState.authorReviewerInboxVisibleNotificationIds = []; - const summary = appState.authorCollaborationSummary || {}; - if (!appState.activeDraftVersionId) { - clearNode(els.authorCollaboration, "这里会显示 anchored comments、blocking threads 与审批状态。"); - clearNode(els.authorReviewerInbox, "这里会显示 reviewer inbox、notifications 与待处理 approval。"); - return; - } - const approval = summary.approval_summary || {}; - const notificationSummary = summary.notification_summary || {}; - const draftWatcherSummary = summary.draft_watcher_summary || {}; - const reviewerId = activeAuthorReviewerId(); - const inbox = appState.authorReviewerInbox || {}; - const threads = summary.threads || []; - const selectedThread = - threads.find((item) => item.thread_id === appState.selectedAuthorThreadId) || - threads[0] || - null; - const card = createListCard({ - title: "Collaboration Summary", - score: summary.recommended_next_action || "-", - body: - `open ${summary.open_thread_count ?? 0} · blocking ${summary.blocking_thread_count ?? 0}\napproval ${approval.latest_status || "-"}\nnotifications unread ${notificationSummary.unread_count ?? 0} / total ${notificationSummary.notification_count ?? 0}\nqueue ${(summary.queue_summary?.status_counts && Object.entries(summary.queue_summary.status_counts).map(([key, value]) => `${key}=${value}`).join(" / ")) || "-"}\nthreads by anchor ${(summary.threads_by_anchor || []).map((item) => `${item.anchor_type}:${item.anchor_key}=${item.thread_count}`).join(" / ") || "-"}` - }); - els.authorCollaboration.appendChild(card); - if ((summary.assignee_queues || []).length) { - els.authorCollaboration.appendChild( - createListCard({ - title: "Assignee Queues", - score: `${summary.assignee_queues.length} queues`, - body: - `${summary.assignee_queues.map((item) => `${item.assignee_id} · open ${item.open_count} · blocking ${item.blocking_count} · total ${item.thread_count}`).join("\n") || "-"}` - }) - ); - } - if ((draftWatcherSummary.watcher_ids || []).length) { - els.authorCollaboration.appendChild( - createListCard({ - title: "Draft Watchers", - score: `${draftWatcherSummary.watcher_count ?? 0} watchers`, - body: - `explicit ${draftWatcherSummary.explicit_watcher_count ?? 0}\nwatchers ${(draftWatcherSummary.watcher_ids || []).join(" / ") || "-"}` - }) - ); - } - if (selectedThread) { - const selectedCard = document.createElement("article"); - selectedCard.className = "list-card is-active"; - selectedCard.innerHTML = ` -
-

Thread Detail · ${selectedThread.anchor_type}:${selectedThread.anchor_key}

- ${selectedThread.status || "-"} / ${selectedThread.severity || "-"} -
-

thread ${selectedThread.thread_id}\nassignee ${selectedThread.assignee_id || "-"} · created_by ${selectedThread.created_by || "-"}\nwatchers ${(selectedThread.watcher_ids || []).join(" / ") || "-"}\nnotifications ${selectedThread.unread_notification_count ?? 0} unread / ${selectedThread.notification_count ?? 0}\nlatest ${selectedThread.latest_message_actor_id || "-"} · ${selectedThread.latest_message_at || "-"}

- `; - const selectedActions = document.createElement("div"); - selectedActions.className = "composer-actions"; - const toggleWatchButton = document.createElement("button"); - toggleWatchButton.className = "ghost-action"; - const currentActorId = activeAuthorActorId(); - const isWatching = (selectedThread.watcher_ids || []).includes(currentActorId); - toggleWatchButton.textContent = isWatching ? "Unwatch" : "Watch"; - toggleWatchButton.addEventListener("click", async (event) => { - event.stopPropagation(); - if (isWatching) { - await removeAuthorThreadWatcher(selectedThread.thread_id, currentActorId); - } else { - await addAuthorThreadWatcher(selectedThread.thread_id, currentActorId); - } - }); - selectedActions.appendChild(toggleWatchButton); - if (reviewerId && selectedThread.assignee_id !== reviewerId) { - const assignReviewerButton = document.createElement("button"); - assignReviewerButton.className = "ghost-action"; - assignReviewerButton.textContent = "Assign Reviewer"; - assignReviewerButton.addEventListener("click", async (event) => { - event.stopPropagation(); - await updateAuthorThreadStatusInline(selectedThread.thread_id, selectedThread.status || "open", { - assigneeId: reviewerId, - body: `指派给 ${reviewerId}。`, - }); - }); - selectedActions.appendChild(assignReviewerButton); - } - const toggleStatusButton = document.createElement("button"); - toggleStatusButton.className = "ghost-action"; - toggleStatusButton.textContent = selectedThread.status === "open" ? "Resolve" : "Reopen"; - toggleStatusButton.addEventListener("click", async (event) => { - event.stopPropagation(); - await updateAuthorThreadStatusInline(selectedThread.thread_id, selectedThread.status === "open" ? "resolved" : "open", { - assigneeId: selectedThread.assignee_id || undefined, - body: selectedThread.status === "open" ? "Inline thread detail 标记为已处理。" : "Inline thread detail 重新打开。", - }); - }); - selectedActions.appendChild(toggleStatusButton); - selectedCard.appendChild(selectedActions); - - (selectedThread.messages || []).forEach((message) => { - selectedCard.appendChild( - createListCard({ - title: `${message.actor_id || "-"} · ${message.actor_role || "-"}`, - score: message.created_at || "-", - body: - `${message.body || "-"}\nmentions ${(message.mentioned_actor_ids || []).join(" / ") || "-"}` - }) - ); - }); - - const replyBox = document.createElement("div"); - replyBox.className = "list-card"; - const replyTitle = document.createElement("div"); - replyTitle.className = "list-card-head"; - replyTitle.innerHTML = `

Reply Inline

${activeAuthorActorId()} / ${activeAuthorActorRole()}`; - replyBox.appendChild(replyTitle); - const replyInput = document.createElement("textarea"); - replyInput.rows = 4; - replyInput.placeholder = "输入 thread 回复,可继续用 @mention。"; - replyInput.value = appState.authorInlineReplyDraft || ""; - replyInput.addEventListener("input", () => { - appState.authorInlineReplyDraft = replyInput.value; - }); - replyBox.appendChild(replyInput); - const replyActions = document.createElement("div"); - replyActions.className = "composer-actions"; - const replyButton = document.createElement("button"); - replyButton.className = "ghost-action"; - replyButton.textContent = "Send Reply"; - replyButton.addEventListener("click", async () => { - await replyToSelectedAuthorThread(selectedThread.thread_id); - }); - replyActions.appendChild(replyButton); - replyBox.appendChild(replyActions); - selectedCard.appendChild(replyBox); - els.authorCollaboration.appendChild(selectedCard); - } - if ((notificationSummary.latest_notifications || []).length) { - els.authorCollaboration.appendChild( - createListCard({ - title: "Latest Notifications", - score: `${notificationSummary.unread_count ?? 0} unread`, - body: - `${(notificationSummary.latest_notifications || []).map((item) => `${item.recipient_id} · ${item.notification_type} · ${item.status}\n${item.title}\n${item.body || "-"}`).join("\n\n") || "-"}` - }) - ); - } - threads.slice(0, 8).forEach((thread) => { - const threadCard = createListCard({ - title: `${thread.anchor_type}:${thread.anchor_key}`, - score: `${thread.status || "-"} / ${thread.severity || "-"}`, - body: - `assignee ${thread.assignee_id || "-"} · created_by ${thread.created_by || "-"}\nparticipants ${(thread.participant_ids || []).join(" / ") || "-"}\nmentions ${(thread.mentioned_actor_ids || []).join(" / ") || "-"}\nnotifications ${thread.unread_notification_count ?? 0} unread / ${thread.notification_count ?? 0}\nlatest ${thread.latest_message_preview || "-"}` - , - active: appState.selectedAuthorThreadId === thread.thread_id - }); - threadCard.addEventListener("click", async () => { - await selectAuthorThread(thread.thread_id, thread.world_version_id); - }); - const actions = document.createElement("div"); - actions.className = "composer-actions"; - const focusButton = document.createElement("button"); - focusButton.className = "ghost-action"; - focusButton.textContent = "定位 Anchor"; - focusButton.addEventListener("click", (event) => { - event.stopPropagation(); - prefillAuthorCommentAnchor(thread.anchor_type, thread.anchor_key); - }); - actions.appendChild(focusButton); - if (reviewerId && thread.assignee_id !== reviewerId) { - const assignButton = document.createElement("button"); - assignButton.className = "ghost-action"; - assignButton.textContent = "指派给 Reviewer"; - assignButton.addEventListener("click", async (event) => { - event.stopPropagation(); - await updateAuthorThreadStatusInline(thread.thread_id, thread.status || "open", { - assigneeId: reviewerId, - body: `指派给 ${reviewerId}。`, - }); - }); - actions.appendChild(assignButton); - } - const statusButton = document.createElement("button"); - statusButton.className = "ghost-action"; - statusButton.textContent = thread.status === "open" ? "标记 Resolved" : "重新打开"; - statusButton.addEventListener("click", async (event) => { - event.stopPropagation(); - await updateAuthorThreadStatusInline(thread.thread_id, thread.status === "open" ? "resolved" : "open", { - assigneeId: thread.assignee_id || undefined, - body: thread.status === "open" ? "Reviewer inbox 已处理。" : "重新打开继续跟进。", - }); - }); - actions.appendChild(statusButton); - threadCard.appendChild(actions); - els.authorCollaboration.appendChild(threadCard); - }); - - if (!reviewerId) { - clearNode(els.authorReviewerInbox, "这里会显示 reviewer inbox、notifications 与待处理 approval。"); - return; - } - - if (els.authorLoadMoreReviewerInbox) { - els.authorLoadMoreReviewerInbox.disabled = !appState.authorReviewerInboxHasMore; - els.authorLoadMoreReviewerInbox.textContent = appState.authorReviewerInboxHasMore ? "Load More" : "No More Results"; - } - - els.authorReviewerInbox.appendChild( - createListCard({ - title: "Reviewer Inbox Summary", - score: inbox.recommended_next_action || "-", - body: - `reviewer ${reviewerId}\nassigned ${inbox.queue_summary?.assigned_open_thread_count ?? 0} · blocking ${inbox.queue_summary?.blocking_assigned_thread_count ?? 0}\npending approvals ${inbox.queue_summary?.pending_approval_count ?? 0}\nunread notifications ${inbox.queue_summary?.unread_notification_count ?? 0}\nreturned ${inbox.returned_count ?? (inbox.notifications || []).length} · more ${inbox.has_more ? "yes" : "no"}\nstatus ${(inbox.queue_summary?.status_counts && Object.entries(inbox.queue_summary.status_counts).map(([key, value]) => `${key}=${value}`).join(" / ")) || "-"}\ntypes ${(inbox.queue_summary?.notification_type_counts && Object.entries(inbox.queue_summary.notification_type_counts).map(([key, value]) => `${key}=${value}`).join(" / ")) || "-"}` - }) - ); - - if ((inbox.world_version_queues || []).length) { - els.authorReviewerInbox.appendChild( - createListCard({ - title: "Inbox by Draft", - score: `${inbox.world_version_queues.length} drafts`, - body: - `${(inbox.world_version_queues || []).map((item) => `${item.world_version_id} · unread ${item.unread_count} · total ${item.notification_count}`).join("\n") || "-"}` - }) - ); - } - - (inbox.pending_approvals || []).slice(0, 4).forEach((approvalItem) => { - const approvalCard = createListCard({ - title: `Approval ${approvalItem.world_version_id}`, - score: approvalItem.status || "requested", - body: - `reviewer ${approvalItem.reviewer_id || "-"}\nrevision ${approvalItem.revision_id || "-"}\nreason ${approvalItem.reason || "-"}` - }); - const actions = document.createElement("div"); - actions.className = "composer-actions"; - const approveButton = document.createElement("button"); - approveButton.className = "ghost-action"; - approveButton.textContent = "批准"; - approveButton.addEventListener("click", async () => { - await decideAuthorApprovalForWorld(approvalItem.world_version_id, "approved", reviewerId, "Reviewer inbox 快速批准。"); - }); - actions.appendChild(approveButton); - const changesButton = document.createElement("button"); - changesButton.className = "ghost-action"; - changesButton.textContent = "要求修改"; - changesButton.addEventListener("click", async () => { - await decideAuthorApprovalForWorld(approvalItem.world_version_id, "changes_requested", reviewerId, "Reviewer inbox 要求修改。"); - }); - actions.appendChild(changesButton); - approvalCard.appendChild(actions); - els.authorReviewerInbox.appendChild(approvalCard); - }); - - const visibleNotifications = (inbox.notifications || []).slice(0, 8); - appState.authorReviewerInboxVisibleNotificationIds = visibleNotifications.map((item) => item.notification_id).filter(Boolean); - visibleNotifications.forEach((notification) => { - const notificationCard = createListCard({ - title: notification.title || notification.notification_type || "Notification", - score: `${notification.status || "-"} / ${notification.recipient_role || "-"}`, - body: - `type ${notification.notification_type || "-"} · world ${notification.world_version_id || "-"}\nactor ${notification.actor_id || "-"} · recipient ${notification.recipient_id || "-"}\nanchor ${notification.anchor_type || "-"}:${notification.anchor_key || "-"}\n${notification.body || "-"}` - }); - notificationCard.addEventListener("click", async () => { - if (notification.thread_id) { - await selectAuthorThread(notification.thread_id, notification.world_version_id || ""); - } - }); - const actions = document.createElement("div"); - actions.className = "composer-actions"; - const readButton = document.createElement("button"); - readButton.className = "ghost-action"; - readButton.textContent = "标记已读"; - readButton.addEventListener("click", async (event) => { - event.stopPropagation(); - await updateAuthorNotificationStatus(notification.notification_id, "read"); - }); - actions.appendChild(readButton); - const archiveButton = document.createElement("button"); - archiveButton.className = "ghost-action"; - archiveButton.textContent = "归档"; - archiveButton.addEventListener("click", async (event) => { - event.stopPropagation(); - await updateAuthorNotificationStatus(notification.notification_id, "archived"); - }); - actions.appendChild(archiveButton); - if (notification.anchor_type && notification.anchor_key) { - const focusButton = document.createElement("button"); - focusButton.className = "ghost-action"; - focusButton.textContent = "跳到线程"; - focusButton.addEventListener("click", async (event) => { - event.stopPropagation(); - if (notification.thread_id) { - await selectAuthorThread(notification.thread_id, notification.world_version_id || ""); - } - prefillAuthorCommentAnchor(notification.anchor_type, notification.anchor_key); - }); - actions.appendChild(focusButton); - } - notificationCard.appendChild(actions); - els.authorReviewerInbox.appendChild(notificationCard); - }); - - (inbox.blocking_assigned_threads || []).slice(0, 4).forEach((thread) => { - const blockingCard = createListCard({ - title: `Blocking ${thread.anchor_type}:${thread.anchor_key}`, - score: thread.severity || "blocker", - body: - `thread ${thread.thread_id}\nlatest ${thread.latest_message_preview || "-"}\nstatus ${thread.status || "-"} · assignee ${thread.assignee_id || "-"}` - }); - blockingCard.addEventListener("click", async () => { - await selectAuthorThread(thread.thread_id, thread.world_version_id); - }); - const actions = document.createElement("div"); - actions.className = "composer-actions"; - const resolveButton = document.createElement("button"); - resolveButton.className = "ghost-action"; - resolveButton.textContent = "处理完成"; - resolveButton.addEventListener("click", async (event) => { - event.stopPropagation(); - await updateAuthorThreadStatusInline(thread.thread_id, "resolved", { - actorId: reviewerId, - assigneeId: reviewerId, - body: "Reviewer inbox 标记为已处理。", - }); - }); - actions.appendChild(resolveButton); - blockingCard.appendChild(actions); - els.authorReviewerInbox.appendChild(blockingCard); - }); - - const preferences = appState.authorNotificationPreferences?.preferences || []; - if (!preferences.length) { - clearNode(els.authorNotificationPreferences, "这里会显示当前 actor 的 notification preferences。"); - } else { - els.authorNotificationPreferences.appendChild( - createListCard({ - title: `Notification Preferences · ${appState.authorNotificationPreferences?.actor_id || activeAuthorActorId()}`, - score: `${preferences.length} types`, - body: - `${preferences.map((item) => `${item.notification_type} · in-app ${item.in_app_enabled ? "on" : "off"} · async ${item.async_mirror_enabled ? "on" : "off"} · sink ${item.async_sink_name || "default"} · target ${item.delivery_target || "-"}${item.is_default ? " · default" : ""}`).join("\n") || "-"}` - }) - ); - } -} - -function parseMaybeJson(value) { - if (typeof value !== "string") return value; - try { - return JSON.parse(value); - } catch (error) { - return value; - } -} - -function activeReaderId() { - return els.readerIdInput?.value.trim() || appState.readerId || "reader_demo"; -} - -async function refreshReaderEntitlements() { - const readerId = activeReaderId(); - appState.readerId = readerId; - if (els.readerIdInput) { - els.readerIdInput.value = readerId; - } - const [payload, subscriptionPayload] = await Promise.all([ - api(`/v1/reader/entitlements?account_id=${encodeURIComponent(readerId)}${appState.worldId ? `&world_id=${encodeURIComponent(appState.worldId)}` : ""}`), - api(`/v1/reader/subscription?account_id=${encodeURIComponent(readerId)}`), - ]); - appState.readerSubscription = subscriptionPayload; - appState.readerEntitlements = payload.entitlements || []; - const credits = payload.wallets?.story_credits || appState.readerEntitlements.find((item) => item.entitlement_type === "credits" && item.status === "active"); - const subscriber = subscriptionPayload.subscription || payload.subscription || appState.readerEntitlements.find((item) => item.entitlement_type === "subscriber" && item.status === "active"); - const worldPass = appState.readerEntitlements.find((item) => item.entitlement_type === "world_pass" && item.status === "active"); - els.readerEntitlementType.textContent = subscriber - ? subscriber.tier_id || "subscriber" - : worldPass - ? "world_pass" - : credits - ? credits.wallet_type || "credits" - : "trial"; - if (els.readerSubscriptionStatus) { - els.readerSubscriptionStatus.textContent = subscriber?.status || "inactive"; - } - els.readerCreditBalance.textContent = credits ? String(Number(credits.balance || 0).toFixed(0)) : "-"; - const activePaywall = appState.latestStep?.paywall || appState.sessionPaywall || {}; - if (els.readerWorldUnlockStatus) { - els.readerWorldUnlockStatus.textContent = worldUnlockLabel(activePaywall); - } - if (els.readerEntitlementReason) { - els.readerEntitlementReason.textContent = accessReasonLabel(activePaywall.reason || subscriber?.reason || worldPass?.reason || credits?.reason || "trial_chapter"); - } - clearNode(els.readerEntitlementList); - if (!appState.readerEntitlements.length) { - clearNode(els.readerEntitlementList, "这里会显示当前 reader 的 entitlement 列表。"); - return; - } - appState.readerEntitlements.forEach((item) => { - const card = document.createElement("article"); - card.className = "list-card"; - card.innerHTML = ` -
-

${item.wallet_type || item.tier_id || item.entitlement_type}

- ${item.status} -
-

world ${item.world_id || "all"}\nbalance ${item.balance ?? "-"}\nreason ${accessReasonLabel(item.reason)}\nexpires ${item.expires_at || "-"}

- `; - els.readerEntitlementList.appendChild(card); - }); - if (payload.subscription) { - const card = document.createElement("article"); - card.className = "list-card"; - card.innerHTML = ` -
-

${payload.subscription.tier_id || "subscription"}

- ${payload.subscription.status || "-"} -
-

price ${payload.subscription.price_usd_monthly ? `$${payload.subscription.price_usd_monthly}/month` : "-"}\nprovider ${payload.subscription.provider || "-"}\nperiod ${payload.subscription.period_end || "-"}\nnext ${payload.subscription.next_action || "-"}\nreason ${payload.subscription.lifecycle_reason || "-"}\nretryable ${payload.retryable ? "yes" : "no"} · renewable ${payload.renewable ? "yes" : "no"}\nrecommended ${payload.recommended_action || "-"}

- `; - els.readerEntitlementList.prepend(card); - } - clearNode(els.readerMembershipOffers); - const tiers = subscriptionPayload.tiers || []; - if (!tiers.length) { - clearNode(els.readerMembershipOffers, "这里会显示 Play / Creator / Studio Pass 的方案与 checkout 入口。"); - } else { - tiers.forEach((tier) => { - const card = document.createElement("article"); - card.className = "list-card"; - if (subscriber?.tier_id === tier.tier_id) { - card.classList.add("is-selected"); - } - const buttonLabel = subscriber?.tier_id === tier.tier_id ? "当前方案" : `开始 ${tier.tier_id}`; - card.innerHTML = ` -
-

${tierLabel(tier.tier_id)}

- $${Number(tier.price_usd_monthly || 0).toFixed(0)}/month -
-

${tier.description || "-"}\nreader access ${tier.reader_access ? "yes" : "no"}\nauthor access ${tier.author_access || "none"}\nmonthly story ${tier.monthly_story_credits ?? 0}\nmonthly studio ${tier.monthly_studio_credits ?? 0}\ncapabilities ${(tier.capabilities ? Object.entries(tier.capabilities).filter(([, value]) => value).map(([key]) => key).join(" / ") : "-") || "-"}

-
- -
- `; - const button = card.querySelector(".reader-tier-checkout"); - if (subscriber?.tier_id === tier.tier_id) { - button.disabled = true; - } else { - button.addEventListener("click", () => startReaderCheckout(tier.tier_id)); - } - els.readerMembershipOffers.appendChild(card); - }); - } - clearNode(els.readerCheckoutStatus); - if (!appState.readerCheckoutSession) { - clearNode(els.readerCheckoutStatus, "这里会显示最近一次 checkout 创建结果。"); - } else { - const checkout = appState.readerCheckoutSession; - const card = document.createElement("article"); - card.className = "list-card"; - card.innerHTML = ` -
-

${tierLabel(checkout.tier_id)}

- ${checkout.status || "-"} -
-

provider ${checkout.provider || "-"}\nsession ${checkout.session_id || checkout.checkout_session_id || "-"}\nexpires ${checkout.expires_at || "-"}\nurl ${checkout.checkout_url || "-"}

- `; - els.readerCheckoutStatus.appendChild(card); - } - if (payload.lifecycle_history_summary?.latest_events?.length) { - payload.lifecycle_history_summary.latest_events.slice(0, 4).forEach((item) => { - const card = document.createElement("article"); - card.className = "list-card"; - card.innerHTML = ` -
-

${item.event_type || "-"}

- ${item.status || "-"} -
-

${formatTimestamp(item.occurred_at)}\nprovider ${item.provider || "-"}\nsubscription ${item.subscription_id || "-"}\ncheckout ${item.checkout_session_id || "-"}

- `; - els.readerCheckoutStatus.appendChild(card); - }); - } -} - -async function startReaderCheckout(tierId = "play_pass") { - const accountId = activeReaderId(); - const restore = setBusy(els.readerStartCheckout, "创建中…"); - try { - const payload = await api("/v1/reader/checkout/start", { - method: "POST", - body: JSON.stringify({ - account_id: accountId, - tier_id: tierId, - provider: "web_stub", - }), - }); - appState.readerCheckoutSession = payload.checkout; - renderLatestStep(); - await refreshReaderEntitlements(); - } catch (error) { - alert(`创建 checkout 失败:${error.message}`); - } finally { - restore(); - } -} - -async function retryReaderSubscriptionPayment() { - const accountId = activeReaderId(); - try { - await api(`/v1/reader/subscription/${encodeURIComponent(accountId)}/retry-payment`, { method: "POST" }); - await refreshReaderEntitlements(); - } catch (error) { - alert(`重试支付失败:${error.message}`); - } -} - -async function renewReaderSubscription() { - const accountId = activeReaderId(); - try { - await api(`/v1/reader/subscription/${encodeURIComponent(accountId)}/renew`, { method: "POST" }); - await refreshReaderEntitlements(); - } catch (error) { - alert(`续费失败:${error.message}`); - } -} - -async function cancelReaderSubscription() { - const accountId = activeReaderId(); - try { - await api(`/v1/reader/subscription/${encodeURIComponent(accountId)}/cancel`, { method: "POST" }); - await refreshReaderEntitlements(); - } catch (error) { - alert(`取消订阅失败:${error.message}`); - } -} - -async function grantReaderEntitlement() { - const readerId = activeReaderId(); - const entitlementType = els.grantEntitlementType?.value || "credits"; - const payload = { - reader_id: readerId, - entitlement_type: entitlementType === "story_credits" ? "credits" : entitlementType, - }; - if (entitlementType === "story_credits") { - payload.wallet_type = "story_credits"; - payload.balance = Number(els.grantEntitlementBalance?.value || 3); - } - if (entitlementType === "world_pass" && appState.worldId) { - payload.world_id = appState.worldId; - } - await api("/v1/reader/entitlements/grant", { - method: "POST", - body: JSON.stringify(payload), - }); - await refreshReaderEntitlements(); - updateStatus(); -} - -function reviewStatusLabel(status) { - return { - submitted: "已提交审核", - approved: "审核通过", - published: "已发布", - rolled_back: "已回滚", - publish_blocked: "发布被阻止", - }[status] || status; -} - -function summarizeChecklistEvidence(evidence) { - if (!evidence || typeof evidence !== "object") return "-"; - const parts = []; - if (evidence.cross_pack_pass_rate !== undefined && evidence.cross_pack_pass_rate !== null) { - parts.push(`cross-pack ${Number(evidence.cross_pack_pass_rate || 0).toFixed(3)}`); - } - if (evidence.cross_pack_pass_rate_delta !== undefined && evidence.cross_pack_pass_rate_delta !== null) { - parts.push(`delta ${Number(evidence.cross_pack_pass_rate_delta || 0).toFixed(3)}`); - } - if (evidence.block_rate !== undefined && evidence.block_rate !== null) { - parts.push(`block ${formatPercent(evidence.block_rate)}`); - } - if (evidence.max_prose_leak_rate !== undefined && evidence.max_prose_leak_rate !== null) { - parts.push(`max leak ${Number(evidence.max_prose_leak_rate || 0).toFixed(3)}`); - } - if (Array.isArray(evidence.top_failing_pack_ids) && evidence.top_failing_pack_ids.length) { - parts.push(`weak ${evidence.top_failing_pack_ids.join(" / ")}`); - } - if (Array.isArray(evidence.regressions) && evidence.regressions.length) { - parts.push(`regressions ${evidence.regressions.join(" / ")}`); - } - if (Array.isArray(evidence.leaking_worlds) && evidence.leaking_worlds.length) { - parts.push(`leaks ${evidence.leaking_worlds.map((item) => `${item.world_id}:${Number(item.prose_leak_rate || 0).toFixed(3)}`).join(" / ")}`); - } - if (evidence.latest_decision) { - parts.push(`decision ${evidence.latest_decision}`); - } - if (evidence.present !== undefined) { - parts.push(`present ${evidence.present ? "yes" : "no"}`); - } - if (evidence.completed_chapters !== undefined && evidence.completed_chapters !== null) { - parts.push(`chapters ${evidence.completed_chapters}`); - } - return parts.join(" · ") || JSON.stringify(evidence); -} - -function applySupportPrefill(prefill = {}) { - if (prefill.account_id && els.opsAccountId) { - els.opsAccountId.value = prefill.account_id; - } - if (prefill.wallet_type && els.opsWalletType) { - els.opsWalletType.value = prefill.wallet_type; - } - if (prefill.amount !== undefined && prefill.amount !== null && els.opsWalletAmount) { - els.opsWalletAmount.value = String(prefill.amount); - } - if (prefill.tier_id && els.opsTierId) { - els.opsTierId.value = prefill.tier_id; - } - if (prefill.subscription_status && els.opsSubscriptionStatus) { - els.opsSubscriptionStatus.value = prefill.subscription_status; - } - if (prefill.entitlement_id && els.opsEntitlementId) { - els.opsEntitlementId.value = prefill.entitlement_id; - } - if (prefill.entitlement_reason && els.opsEntitlementReason) { - els.opsEntitlementReason.value = prefill.entitlement_reason; - } -} - -function applyGovernanceCasePrefill(prefill = {}) { - if (prefill.account_id && els.opsAccountId) { - els.opsAccountId.value = prefill.account_id; - } - if (prefill.case_id && els.opsGovernanceCaseId) { - els.opsGovernanceCaseId.value = prefill.case_id; - } - if (prefill.case_type && els.opsGovernanceCaseType) { - els.opsGovernanceCaseType.value = prefill.case_type; - } - if (prefill.target_type && els.opsGovernanceTargetType) { - els.opsGovernanceTargetType.value = prefill.target_type; - } - if (prefill.target_id && els.opsGovernanceTargetId) { - els.opsGovernanceTargetId.value = prefill.target_id; - } - if (prefill.severity && els.opsGovernanceSeverity) { - els.opsGovernanceSeverity.value = prefill.severity; - } - if (prefill.reviewer_id && els.opsGovernanceReviewerId) { - els.opsGovernanceReviewerId.value = prefill.reviewer_id; - } - if (prefill.owner_id && els.opsGovernanceOwnerId) { - els.opsGovernanceOwnerId.value = prefill.owner_id; - } - if (prefill.summary && els.opsGovernanceSummaryInput) { - els.opsGovernanceSummaryInput.value = prefill.summary; - } - if (prefill.description && els.opsGovernanceNotes) { - els.opsGovernanceNotes.value = prefill.description; - } - if (prefill.status && els.opsGovernanceStatus) { - els.opsGovernanceStatus.value = prefill.status; - } - if (prefill.due_at && els.opsGovernanceDueAt) { - els.opsGovernanceDueAt.value = prefill.due_at; - } - if (prefill.disposition && els.opsGovernanceDisposition) { - els.opsGovernanceDisposition.value = prefill.disposition; - } - if (prefill.policy_labels && els.opsGovernancePolicyLabels) { - els.opsGovernancePolicyLabels.value = Array.isArray(prefill.policy_labels) ? prefill.policy_labels.join(", ") : String(prefill.policy_labels); - } -} - -async function openLearnedWorldDetail(worldId) { - appState.opsLearnedDetail = await api(`/v1/ops/learned-dashboard/worlds/${worldId}`); - renderOpsSurface(scopes); -} - -async function openLearnedIssueDetail(issueCode) { - appState.opsLearnedDetail = await api(`/v1/ops/learned-dashboard/issues/${issueCode}`); - renderOpsSurface(); -} - -function selectReviewBacklogItem(item) { - appState.opsReviewCaptureTarget = item; - if (els.opsReviewIssueCodes) { - els.opsReviewIssueCodes.value = (item.issue_codes || []).join(","); - } - if (els.opsReviewNotes) { - els.opsReviewNotes.value = item.summary || ""; - } - if (els.opsReviewScore) { - els.opsReviewScore.value = item.score_overall !== null && item.score_overall !== undefined - ? Number(item.score_overall).toFixed(2) - : "0.65"; - } - if (els.opsReviewWouldContinue) { - els.opsReviewWouldContinue.checked = item.decision !== "block"; - } - if (els.opsReviewWouldPay) { - els.opsReviewWouldPay.checked = item.decision === "pass"; - } - if (els.opsPreferenceNotes) { - els.opsPreferenceNotes.value = item.summary || ""; - } - if (els.opsRankingNotes) { - els.opsRankingNotes.value = item.summary || ""; - } - renderOpsSurface(); -} - -async function submitOpsReviewCapture() { - if (!appState.opsReviewCaptureTarget) { - alert("先从 Review Backlog 里选择一条章节。"); - return; - } - const reviewerId = els.opsReviewerId?.value.trim() || "ops_web"; - const issueCodes = parseIssueCodes(els.opsReviewIssueCodes?.value || ""); - if (!reviewerId || !issueCodes.length) { - alert("请至少填写 reviewer_id 和 issue codes。"); - return; - } - const restore = setBusy(els.opsSubmitReviewCapture, "提交中…"); - try { - const result = await api("/v1/ops/review-samples", { - method: "POST", - body: JSON.stringify({ - chapter_id: appState.opsReviewCaptureTarget.chapter_id, - world_id: appState.opsReviewCaptureTarget.world_id, - world_version_id: appState.opsReviewCaptureTarget.world_version_id, - session_id: appState.opsReviewCaptureTarget.session_id, - reviewer_id: reviewerId, - score_overall: Number(els.opsReviewScore?.value || 0.65), - issue_codes: issueCodes, - freeform_notes: els.opsReviewNotes?.value || "", - would_continue: Boolean(els.opsReviewWouldContinue?.checked), - would_pay: Boolean(els.opsReviewWouldPay?.checked), - }), - }); - appState.opsLastActionImpact = result.impact_receipt || null; - appState.opsReviewCaptureTarget = null; - if (els.opsReviewNotes) els.opsReviewNotes.value = ""; - if (els.opsReviewIssueCodes) els.opsReviewIssueCodes.value = ""; - await refreshOpsSurface({ preserveLastActionImpact: true }); - } catch (error) { - alert(`提交 Human Review 失败:${error.message}`); - } finally { - restore(); - } -} - -async function submitOpsPreferenceCapture() { - if (!appState.opsReviewCaptureTarget) { - alert("先从 Review Backlog 里选择一条章节,作为 preference 的上下文。"); - return; - } - const reviewerId = els.opsReviewerId?.value.trim() || "ops_web"; - const leftRevisionId = els.opsPreferenceLeftRevisionId?.value.trim() || ""; - const rightRevisionId = els.opsPreferenceRightRevisionId?.value.trim() || ""; - const preferredRevisionId = els.opsPreferencePreferredRevisionId?.value.trim() || ""; - if (!reviewerId || !leftRevisionId || !rightRevisionId || !preferredRevisionId) { - alert("请填写 reviewer_id、left/right revision id 和 preferred revision id。"); - return; - } - const restore = setBusy(els.opsSubmitPreferenceCapture, "提交中…"); - try { - await api("/v1/ops/preference-samples", { - method: "POST", - body: JSON.stringify({ - world_id: appState.opsReviewCaptureTarget.world_id, - world_version_id: appState.opsReviewCaptureTarget.world_version_id, - chapter_id: appState.opsReviewCaptureTarget.chapter_id, - session_id: appState.opsReviewCaptureTarget.session_id, - reviewer_id: reviewerId, - left_revision_id: leftRevisionId, - right_revision_id: rightRevisionId, - preferred_revision_id: preferredRevisionId, - freeform_notes: els.opsPreferenceNotes?.value || "", - linked_issue_codes: parseIssueCodes(els.opsReviewIssueCodes?.value || ""), - preference_strength: els.opsPreferenceStrength?.value || "medium", - }), - }); - if (els.opsPreferenceNotes) els.opsPreferenceNotes.value = ""; - await refreshOpsLearnedFlow(); - } catch (error) { - alert(`提交 Preference 失败:${error.message}`); - } finally { - restore(); - } -} - -async function submitOpsRankingCapture() { - if (!appState.opsReviewCaptureTarget) { - alert("先从 Review Backlog 里选择一条章节,作为 ranking 的上下文。"); - return; - } - const reviewerId = els.opsReviewerId?.value.trim() || "ops_web"; - const rankedRevisionIds = (els.opsRankingRevisionIds?.value || "") - .split(",") - .map((item) => item.trim()) - .filter(Boolean); - if (!reviewerId || rankedRevisionIds.length < 2) { - alert("请填写 reviewer_id,且 ranked revision ids 至少包含两个。"); - return; - } - const restore = setBusy(els.opsSubmitRankingCapture, "提交中…"); - try { - await api("/v1/ops/ranking-samples", { - method: "POST", - body: JSON.stringify({ - world_id: appState.opsReviewCaptureTarget.world_id, - world_version_id: appState.opsReviewCaptureTarget.world_version_id, - chapter_id: appState.opsReviewCaptureTarget.chapter_id, - session_id: appState.opsReviewCaptureTarget.session_id, - reviewer_id: reviewerId, - ranked_revision_ids: rankedRevisionIds, - freeform_notes: els.opsRankingNotes?.value || "", - linked_issue_codes: parseIssueCodes(els.opsReviewIssueCodes?.value || ""), - }), - }); - if (els.opsRankingNotes) els.opsRankingNotes.value = ""; - if (els.opsRankingRevisionIds) els.opsRankingRevisionIds.value = ""; - await refreshOpsLearnedFlow(); - } catch (error) { - alert(`提交 Ranking 失败:${error.message}`); - } finally { - restore(); - } -} - -async function submitOpsPreferenceCapture() { - if (!appState.opsReviewCaptureTarget) { - alert("先从 Review Backlog 里选择一条章节,作为 preference 的上下文。"); - return; - } - const reviewerId = els.opsReviewerId?.value.trim() || "ops_web"; - const leftRevisionId = els.opsPreferenceLeftRevisionId?.value.trim() || ""; - const rightRevisionId = els.opsPreferenceRightRevisionId?.value.trim() || ""; - const preferredRevisionId = els.opsPreferencePreferredRevisionId?.value.trim() || ""; - if (!reviewerId || !leftRevisionId || !rightRevisionId || !preferredRevisionId) { - alert("请填写 reviewer_id、left/right revision id 和 preferred revision id。"); - return; - } - const restore = setBusy(els.opsSubmitPreferenceCapture, "提交中…"); - try { - await api("/v1/ops/preference-samples", { - method: "POST", - body: JSON.stringify({ - world_id: appState.opsReviewCaptureTarget.world_id, - world_version_id: appState.opsReviewCaptureTarget.world_version_id, - chapter_id: appState.opsReviewCaptureTarget.chapter_id, - session_id: appState.opsReviewCaptureTarget.session_id, - reviewer_id: reviewerId, - left_revision_id: leftRevisionId, - right_revision_id: rightRevisionId, - preferred_revision_id: preferredRevisionId, - freeform_notes: els.opsPreferenceNotes?.value || "", - linked_issue_codes: parseIssueCodes(els.opsReviewIssueCodes?.value || ""), - preference_strength: els.opsPreferenceStrength?.value || "medium", - }), - }); - await refreshOpsLearnedFlow(); - } catch (error) { - alert(`提交 Preference 失败:${error.message}`); - } finally { - restore(); - } -} - -async function submitOpsRankingCapture() { - if (!appState.opsReviewCaptureTarget) { - alert("先从 Review Backlog 里选择一条章节,作为 ranking 的上下文。"); - return; - } - const reviewerId = els.opsReviewerId?.value.trim() || "ops_web"; - const rankedRevisionIds = (els.opsRankingRevisionIds?.value || "") - .split(",") - .map((item) => item.trim()) - .filter(Boolean); - if (!reviewerId || rankedRevisionIds.length < 2) { - alert("请填写 reviewer_id,且 ranked revision ids 至少包含两个。"); - return; - } - const restore = setBusy(els.opsSubmitRankingCapture, "提交中…"); - try { - await api("/v1/ops/ranking-samples", { - method: "POST", - body: JSON.stringify({ - world_id: appState.opsReviewCaptureTarget.world_id, - world_version_id: appState.opsReviewCaptureTarget.world_version_id, - chapter_id: appState.opsReviewCaptureTarget.chapter_id, - session_id: appState.opsReviewCaptureTarget.session_id, - reviewer_id: reviewerId, - ranked_revision_ids: rankedRevisionIds, - freeform_notes: els.opsRankingNotes?.value || "", - linked_issue_codes: parseIssueCodes(els.opsReviewIssueCodes?.value || ""), - }), - }); - await refreshOpsLearnedFlow(); - } catch (error) { - alert(`提交 Ranking 失败:${error.message}`); - } finally { - restore(); - } -} - -function syncViewMode() { - els.appShell.dataset.view = appState.activeView; - els.viewExperience.classList.toggle("is-active", appState.activeView === "experience"); - els.viewStorybook.classList.toggle("is-active", appState.activeView === "storybook"); - els.viewBackstage.classList.toggle("is-active", appState.activeView === "backstage"); - els.experienceView.classList.toggle("is-hidden", appState.activeView !== "experience"); - els.storybookView.classList.toggle("is-hidden", appState.activeView !== "storybook"); - els.backstageView.classList.toggle("is-hidden", appState.activeView !== "backstage"); -} - -function syncProductMode() { - els.modeReader.classList.toggle("is-active", appState.activeProduct === "reader"); - els.modeAuthor.classList.toggle("is-active", appState.activeProduct === "author"); - els.modeOps.classList.toggle("is-active", appState.activeProduct === "ops"); - els.readerShell.classList.toggle("is-hidden", appState.activeProduct !== "reader"); - els.authorShell.classList.toggle("is-hidden", appState.activeProduct !== "author"); - els.opsShell.classList.toggle("is-hidden", appState.activeProduct !== "ops"); -} - -function updateStatus() { - els.worldStatus.textContent = appState.worldId ? "已加载" : "未加载"; - els.sessionStatus.textContent = appState.sessionId ? "运行中" : "未创建"; - els.turnStatus.textContent = appState.currentState ? String(appState.currentState.turn_index) : "-"; - els.worldVersionStatus.textContent = appState.worldVersionId || "-"; - const activePaywall = appState.latestStep?.paywall || appState.sessionPaywall || {}; - const creditEntitlement = appState.readerEntitlements.find((item) => item.entitlement_type === "credits" && item.status === "active"); - els.accessTierStatus.textContent = activePaywall.access_tier || "试读"; - els.quoteStatus.textContent = activePaywall.quote ? `¥${Number(activePaywall.quote).toFixed(2)}` : "¥0.00"; - els.worldId.textContent = appState.sessionId ? "已经开始" : "尚未启程"; - els.sessionId.textContent = appState.currentBundle - ? (appState.currentBundle.world_bible.creator_controls?.theme_targets || appState.currentBundle.world_bible.themes || []) - .slice(0, 3) - .join(" / ") || "未设定" - : "-"; - els.previewRoute.disabled = !appState.currentState || !appState.currentBundle; - els.stepSession.disabled = !appState.sessionId; - - if (appState.currentState) { - els.factCount.textContent = String(appState.currentState.world_facts.length); - els.promiseCount.textContent = String(appState.currentState.open_promises.length); - els.tensionValue.textContent = Number(appState.currentState.tension).toFixed(2); - els.sceneWindow.textContent = - appState.currentState.recent_scene_functions.length > 0 - ? appState.currentState.recent_scene_functions.join(" / ") - : "-"; - } else { - els.factCount.textContent = "0"; - els.promiseCount.textContent = "0"; - els.tensionValue.textContent = "0.00"; - els.sceneWindow.textContent = "-"; - } - if (els.readerCreditBalance) { - els.readerCreditBalance.textContent = creditEntitlement ? String(Number(creditEntitlement.balance || 0).toFixed(0)) : "-"; - } - if (els.readerWorldUnlockStatus) { - els.readerWorldUnlockStatus.textContent = worldUnlockLabel(activePaywall); - } - if (els.readerEntitlementReason) { - els.readerEntitlementReason.textContent = accessReasonLabel(activePaywall.reason); - } -} - -function renderIntentPrefill() { - if (!appState.intentPrefill) { - els.currentPressureText.textContent = "故事还没真正卷起来。"; - els.lastIntentText.textContent = "-"; - els.suggestedPrefillText.textContent = "我想先看看这条命会把我带去哪里。"; - return; - } - els.currentPressureText.textContent = appState.intentPrefill.current_pressure || "上一章留下的余波还没散。"; - els.lastIntentText.textContent = appState.intentPrefill.last_player_intent || "-"; - els.suggestedPrefillText.textContent = appState.intentPrefill.suggested_prefill || ""; - if (!els.playerInput.value.trim()) { - els.playerInput.value = appState.intentPrefill.suggested_prefill || ""; - } -} - -function worldDisplayMeta(example) { - if (example.example_id === "romance") { - return { - mood: "爱 / 自我 / 迟疑", - hook: "更适合试探、坦白和关系拉扯。", - }; - } - return { - mood: "职责 / 名誉 / 自我", - hook: "更适合承诺、权衡和命运抉择。", - }; -} - -function renderWorldGallery() { - clearNode(els.worldGallery); - for (const example of appState.examples) { - const meta = worldDisplayMeta(example); - const shelfWorld = appState.shelfWorlds.find((item) => item.world_id === example.world_id); - const card = document.createElement("article"); - card.className = "world-card"; - card.dataset.exampleId = example.example_id; - if (appState.currentBundle?.example_id === example.example_id) { - card.classList.add("is-selected"); - } - card.innerHTML = ` -

${example.label}

-

${example.description}

-
- ${meta.mood} - ${shelfWorld?.risk_rating || "PG-13"} / ${shelfWorld?.access_state || "trial"} -
-
- - -
- `; - card.querySelector(".world-card-preview").addEventListener("click", async () => { - await loadExampleBundle(example.example_id); - }); - card.querySelector(".world-card-start").addEventListener("click", async (event) => { - await loadExampleBundle(example.example_id); - await bootstrapWorld(event.currentTarget); - }); - els.worldGallery.appendChild(card); - } -} - -function renderSessionLibrary() { - clearNode(els.sessionLibrary); - if (!appState.sessionLibrary.length) { - clearNode(els.sessionLibrary, "你还没有在这个世界里留下脚印。开始一段新旅程吧。"); - return; - } - - for (const session of appState.sessionLibrary) { - const card = document.createElement("article"); - card.className = "session-card"; - if (appState.sessionId === session.session_id) { - card.classList.add("is-selected"); - } - card.innerHTML = ` -

${session.last_chapter_title || session.last_event_title || "刚刚开始"}

-

- 已经走到第 ${session.current_turn_index} 幕。${formatTimestamp(session.created_at)} 留下这段旅程。 -

-
- ${session.current_turn_index} 幕 - ${session.last_chapter_title || session.last_event_title ? "可继续阅读" : "等待第一幕"} -
-
- - -
- `; - card.querySelector(".session-card-open").addEventListener("click", async (event) => { - await restoreSession(session.session_id, event.currentTarget); - }); - card.querySelector(".session-card-delete").addEventListener("click", async () => { - await deleteSession(session.session_id); - }); - els.sessionLibrary.appendChild(card); - } -} - -function renderSuggestedInputs() { - clearNode(els.suggestedInputs); - if (!appState.currentBundle) return; - for (const item of appState.currentBundle.player_inputs) { - const fragment = els.suggestionTemplate.content.cloneNode(true); - const button = fragment.querySelector("button"); - button.textContent = item.raw_input; - button.addEventListener("click", () => { - els.playerInput.value = item.raw_input; - appState.selectedIntentOverride = item.intent_vector || null; - }); - els.suggestedInputs.appendChild(fragment); - } -} - -function renderRoutePreview() { - if (!appState.latestPreview?.routes?.length) { - clearNode(els.routePreview, "还没有看到命运分岔。先开始一段旅程,再点“看看接下来”。"); - return; - } - clearNode(els.routePreview); - const ranks = ["最有可能", "另一种走向", "隐秘支线"]; - appState.latestPreview.routes.forEach((route, index) => { - const leadEvent = route.events?.[0]; - const line = document.createElement("div"); - line.className = "route-line"; - line.innerHTML = ` - ${ranks[index] || "可能的命运"} - ${leadEvent?.title || route.event_ids.join(" → ")} - 命运热度 ${route.total_score.toFixed(3)} -

${leadEvent?.summary || route.explanation}

- `; - els.routePreview.appendChild(line); - }); -} - -function spotlightPreviewResult() { - if (!els.routePreviewPanel) return; - els.routePreviewPanel.classList.remove("is-highlighted"); - void els.routePreviewPanel.offsetWidth; - els.routePreviewPanel.classList.add("is-highlighted"); - els.routePreviewPanel.scrollIntoView({ behavior: "smooth", block: "start" }); - window.setTimeout(() => { - els.routePreviewPanel?.classList.remove("is-highlighted"); - }, 1400); -} - -function spotlightChapter() { - if (!els.chapterPanel) return; - els.chapterPanel.classList.remove("is-highlighted"); - void els.chapterPanel.offsetWidth; - els.chapterPanel.classList.add("is-highlighted"); - els.chapterPanel.scrollIntoView({ behavior: "smooth", block: "start" }); - window.setTimeout(() => { - els.chapterPanel?.classList.remove("is-highlighted"); - }, 1400); -} - -function renderCards(target, items, formatter, emptyText) { - clearNode(target); - if (!items.length) { - clearNode(target, emptyText); - return; - } - target.classList.remove("empty-state"); - for (const item of items) { - const fragment = els.listCardTemplate.content.cloneNode(true); - const title = fragment.querySelector("h3"); - const score = fragment.querySelector(".list-card-score"); - const body = fragment.querySelector(".list-card-body"); - const formatted = formatter(item); - title.textContent = formatted.title; - score.textContent = formatted.score; - body.textContent = formatted.body; - if (formatted.active) { - fragment.querySelector(".list-card").classList.add("is-active"); - } - target.appendChild(fragment); - } -} - -function setTone(tone) { - appState.activeTone = tone; - for (const pill of els.tonePills) { - pill.classList.toggle("is-active", pill.dataset.tone === tone); - } - renderStorybook(); - renderStoryFeed(); -} - -function getStorySource() { - if (appState.selectedReplayIndex !== null && appState.replay?.event_trace?.[appState.selectedReplayIndex]) { - return { - event: appState.replay.event_trace[appState.selectedReplayIndex], - rendered: appState.replay.rendered_scenes?.[appState.selectedReplayIndex] || null, - reader_view: appState.replay.reader_views?.[appState.selectedReplayIndex] || null, - index: appState.selectedReplayIndex, - }; - } - if (appState.latestStep?.chosen_event) { - return { - event: appState.latestStep.chosen_event, - rendered: appState.latestStep.rendered_scene, - reader_view: appState.latestStep.reader_view || null, - index: null, - }; - } - return null; -} - -function renderStorybook() { - const source = getStorySource(); - if (!source) { - els.storyHero.dataset.motif = ""; - els.storyTitle.textContent = "画面会在这里展开"; - els.storyCaption.textContent = "推进一幕之后,这里会变成一张带情绪和光影的故事画面。"; - els.storyQuote.textContent = "当故事开始流动,这里会出现一句最能代表这一幕的引句。"; - els.storyPrompt.textContent = "-"; - els.storyMotif.textContent = "-"; - clearNode(els.storyBeats, "这里会显示这一幕最值得抓住的三个节拍。"); - clearNode(els.storyDetails, "这里会显示画面中的气味、动作和情绪提示。"); - els.storyProse.textContent = "这里会显示图文版本对应的正文。"; - clearNode(els.storySequence, "故事累积起来后,这里会变成一条可以回看的章节画卷。"); - return; - } - - const rendered = source.rendered || {}; - const readerView = source.reader_view || {}; - const sceneCard = readerView.scene_card || {}; - els.storyHero.dataset.motif = rendered.image_motif || source.event.scene_function || ""; - els.storyTitle.textContent = readerView.chapter_title || rendered.story_title || source.event.title || "当前剧情"; - els.storyCaption.textContent = readerView.recap || rendered.chapter_summary || rendered.image_caption || source.event.summary || "暂无说明。"; - els.storyQuote.textContent = sceneCard.quote || rendered.pull_quote || "这一幕还没有留下自己的引句。"; - els.storyPrompt.textContent = sceneCard.summary || rendered.visual_prompt || "暂无 visual prompt"; - els.storyMotif.textContent = sceneCard.palette_hint || rendered.image_motif || source.event.scene_function || "-"; - clearNode(els.storyBeats); - const beatItems = sceneCard.story_beats || rendered.story_beats || []; - if (beatItems.length) { - beatItems.forEach((beat) => { - const node = document.createElement("span"); - node.className = "story-beat"; - node.textContent = beat; - els.storyBeats.appendChild(node); - }); - } else { - clearNode(els.storyBeats, "这里会显示这一幕最值得抓住的三个节拍。"); - } - clearNode(els.storyDetails); - const detailItems = sceneCard.visual_details || rendered.visual_details || []; - if (detailItems.length) { - detailItems.forEach((detail) => { - const node = document.createElement("span"); - node.className = "story-detail"; - node.textContent = detail; - els.storyDetails.appendChild(node); - }); - } else { - clearNode(els.storyDetails, "这里会显示画面中的气味、动作和情绪提示。"); - } - els.storyProse.textContent = - readerView.body || - rendered[appState.activeTone] || - rendered.premium_prose || - source.event.summary || - "暂无正文。"; - - clearNode(els.storySequence); - if (!appState.replay?.event_trace?.length) { - clearNode(els.storySequence, "故事累积起来后,这里会变成一条可以回看的章节画卷。"); - return; - } - - appState.replay.event_trace.forEach((event, index) => { - const renderedScene = appState.replay.rendered_scenes?.[index] || {}; - const readerView = appState.replay.reader_views?.[index] || {}; - const card = document.createElement("article"); - card.className = "story-sequence-card"; - if (index === appState.selectedReplayIndex) { - card.classList.add("is-active"); - } - card.innerHTML = ` -

Turn ${index + 1} · ${readerView.chapter_title || renderedScene.story_title || event.title}

-

${readerView.recap || renderedScene.chapter_summary || renderedScene.image_caption || event.summary}

- `; - card.addEventListener("click", () => { - appState.selectedReplayIndex = index; - renderReplay(); - renderStorybook(); - }); - els.storySequence.appendChild(card); - }); -} - -function renderLatestStep() { - if (!appState.latestStep) { - els.chosenEventTitle.textContent = "故事还没开始"; - els.bestRoute.textContent = "当你写下一句心意,系统会在这里接住它。"; - clearNode(els.storyFeed, "载入 world 并执行一步后,这里会按时间顺序出现连续章节。"); - clearNode(els.scoredCandidates, "幕后会在这里比较不同走向。"); - clearNode(els.criticTrace, "幕后会在这里解释为什么这条线更成立。"); - els.lastEventTitle.textContent = "-"; - els.paywallBanner.classList.add("is-hidden"); - renderStorybook(); - renderIntentPrefill(); - return; - } - - const readerView = appState.latestStep.reader_view || {}; - els.chosenEventTitle.textContent = readerView.chapter_title || appState.latestStep.chosen_event.title; - els.lastEventTitle.textContent = readerView.chapter_title || appState.latestStep.chosen_event.title; - els.bestRoute.textContent = appState.latestStep.routes?.length - ? (() => { - const routeEvents = appState.latestStep.routes[0].events || []; - const titles = routeEvents.map((event) => event.title).filter(Boolean); - if (!titles.length) return "主线已经开始往下一处更难退开的命运口子靠近。"; - if (titles.length === 1) return `接下来更可能逼近的是:${titles[0]}。`; - return `接下来更可能先逼近“${titles[0]}”,随后余波会把你带向“${titles[1]}”。`; - })() - : readerView.recap || "此刻还没有新的主线判断。"; - - const batch = appState.latestStep.candidate_batch || { raw_candidates: [], legal_candidates: [], debug: {} }; - els.candidateSummary.textContent = - batch.raw_candidates?.length - ? `系统刚才比对了 ${batch.raw_candidates.length} 种可能,留下 ${batch.legal_candidates.length} 条真正说得通的走向。` - : "幕后会在这里比较不同走向。"; - - renderCards( - els.scoredCandidates, - appState.latestStep.scored_candidates || [], - (item) => ({ - title: item.event.title, - score: `匹配度 ${item.total_score.toFixed(3)}`, - body: - `${item.explanation}\n` + - (item.critic_decisions?.length - ? item.critic_decisions - .map((decision) => `${decision.critic_name}: ${decision.verdict} · ${decision.reasons.join(" / ")}`) - .join("\n") - : "这一条线没有额外诊断备注。"), - }), - "幕后会在这里比较不同走向。" - ); - - renderCards( - els.criticTrace, - appState.latestStep.critic_trace || [], - (item) => ({ - title: item.event_id, - score: `修正 ${Number(item.critic_penalty || 0).toFixed(3)}`, - body: - (item.critic_decisions || []) - .map((decision) => `${decision.critic_name}: ${decision.verdict} · ${decision.reasons.join(" / ")}`) - .join("\n") || "这一步没有额外诊断。", - }), - "幕后会在这里解释为什么这条线更成立。" - ); - - if (appState.latestStep?.paywall?.required) { - els.paywallBanner.classList.remove("is-hidden"); - const paywall = appState.latestStep.paywall; - const tierText = paywall.required_display_name || (paywall.tier_id ? tierLabel(paywall.tier_id) : "付费权益"); - const balanceText = paywall.balance !== null && paywall.balance !== undefined - ? `${Number(paywall.balance).toFixed(0)} Story Credits` - : "没有可用 Story Credits"; - const capabilityText = paywall.required_capability ? `需要能力 ${paywall.required_capability}` : "需要继续阅读权益"; - els.paywallBannerCopy.textContent = `当前继续被拦截:${accessReasonLabel(paywall.reason)}。${capabilityText},推荐通过 ${tierText} 解锁,当前报价 $${Number(paywall.quote || 0).toFixed(2)} / month。你的账户目前有 ${balanceText},当前世界状态:${worldUnlockLabel(paywall)}。`; - els.paywallBannerCheckout.textContent = `解锁 ${tierText}`; - els.paywallBannerCheckout.onclick = () => startReaderCheckout(paywall.suggested_checkout_tier || paywall.tier_id || "play_pass"); - } else { - els.paywallBanner.classList.add("is-hidden"); - if (els.paywallBannerCheckout) { - els.paywallBannerCheckout.onclick = null; - } - } - for (const pill of els.tonePills) { - pill.classList.toggle("is-active", pill.dataset.tone === appState.activeTone); - } - renderStorybook(); - renderStoryFeed(); - renderIntentPrefill(); -} - -function renderStoryFeed() { - const chapters = []; - if (appState.replay?.reader_views?.length) { - appState.replay.reader_views.forEach((readerView, index) => { - chapters.push({ - chapterTitle: readerView.chapter_title, - recap: readerView.recap, - body: readerView.body, - relationshipHints: readerView.relationship_hints || [], - chapterIndex: readerView.chapter_index || index + 1, - }); - }); - } else if (appState.latestStep?.reader_view) { - chapters.push({ - chapterTitle: appState.latestStep.reader_view.chapter_title, - recap: appState.latestStep.reader_view.recap, - body: appState.latestStep.reader_view.body, - relationshipHints: appState.latestStep.reader_view.relationship_hints || [], - chapterIndex: appState.latestStep.reader_view.chapter_index || 1, - }); - } - - clearNode(els.storyFeed); - if (!chapters.length) { - clearNode(els.storyFeed, "载入 world 并执行一步后,这里会按时间顺序出现连续章节。"); - return; - } - - chapters.forEach((chapter, index) => { - const card = document.createElement("article"); - card.className = "story-feed-card"; - if (index === chapters.length - 1) { - card.classList.add("is-active"); - } - card.innerHTML = ` -
-

第 ${chapter.chapterIndex} 章

-

${chapter.chapterTitle}

-
-

${chapter.recap || ""}

-
${chapter.body || ""}
- ${chapter.relationshipHints.length ? `
${chapter.relationshipHints.map((hint) => `${hint}`).join("")}
` : ""} - `; - els.storyFeed.appendChild(card); - }); -} - -function renderReplay() { - if (!appState.replay?.event_trace?.length) { - clearNode(els.replayTimeline, "推进几幕之后,这里会变成一条可回看的章节轨迹。"); - renderStorybook(); - return; - } - renderCards( - els.replayTimeline, - appState.replay.event_trace.map((event, index) => ({ - event, - index, - promises: appState.replay.promise_ledger_snapshots[index] || [], - readerView: appState.replay.reader_views?.[index] || {}, - })), - ({ event, index, promises, readerView }) => ({ - title: `Turn ${index + 1} · ${readerView.chapter_title || event.title}`, - score: event.scene_function || "", - body: - `${readerView.recap || event.summary}\n` + - `未解牵挂: ${promises.length}\n` + - `Tags: ${(event.tags || []).join(", ")}`, - active: index === appState.selectedReplayIndex, - }), - "推进几幕之后,这里会变成一条可回看的章节轨迹。" - ); - renderStorybook(); -} - -function updateBundleSummary() { - if (!appState.currentBundle) { - els.worldTitle.textContent = "选择一个世界"; - els.worldDescription.textContent = "先挑一个世界,再开始一段新的命运旅程。"; - els.featuredWorldTitle.textContent = "先挑一个世界,再开始一段新的命运旅程。"; - els.featuredWorldCopy.textContent = "你会在这里看到这个世界的主命题、情绪底色,以及这一轮旅程最适合怎样推进。"; - els.featuredWorldMood.textContent = "-"; - els.featuredWorldHook.textContent = "-"; - return; - } - const meta = worldDisplayMeta(appState.currentBundle); - els.worldTitle.textContent = appState.currentBundle.label; - els.worldDescription.textContent = appState.currentBundle.description; - els.featuredWorldTitle.textContent = appState.currentBundle.label; - els.featuredWorldCopy.textContent = appState.currentBundle.description; - els.featuredWorldMood.textContent = meta.mood; - els.featuredWorldHook.textContent = meta.hook; -} - -function renderAuthorDrafts() { - clearNode(els.authorDraftList); - if (!appState.authorDrafts.length) { - clearNode(els.authorDraftList, "还没有 draft。先把当前世界保存为 Draft。"); - return; - } - const simulateAccess = appState.authorAccessSnapshot?.actions?.simulate || null; - const submitAccess = appState.authorAccessSnapshot?.actions?.submit_draft || null; - const validateAccess = appState.authorAccessSnapshot?.actions?.validate_draft || null; - appState.authorDrafts.forEach((draft) => { - const card = document.createElement("article"); - card.className = "list-card"; - if (draft.world_version_id === appState.activeDraftVersionId) { - card.classList.add("is-active"); - } - card.innerHTML = ` -
-

${draft.title || draft.world_id}

- ${draft.status} -
-

版本 ${draft.version || draft.world_version_id} · 风险 ${draft.risk_rating || "未定"}

-
- - - -
- `; - card.querySelector(".draft-validate").addEventListener("click", () => { - (async () => { - try { - await validateDraftVersion(draft.world_version_id); - } catch (error) { - const detail = parseErrorDetail(error); - await refreshAuthorSurface(); - if (detail?.code === "author_entitlement_required") { - alertAuthorGating(detail, "校验 Draft"); - return; - } - alert(`校验失败:${error.message}`); - } - })(); - }); - card.querySelector(".draft-simulate").addEventListener("click", async () => { - try { - await simulateDraftVersion(draft.world_version_id); - } catch (error) { - const detail = parseErrorDetail(error); - await refreshAuthorSurface(); - if (detail?.code === "author_entitlement_required") { - alert(`当前不能模拟:${accessReasonLabel(detail.reason)}。需要 ${detail.required_display_name || tierLabel(detail.required_tier)},当前 ${detail.wallet_type || "-"} 余额 ${Number(detail.balance || 0).toFixed(0)}。`); - return; - } - alert(`模拟失败:${error.message}`); - } - }); - if (simulateAccess && !simulateAccess.allowed) { - const button = card.querySelector(".draft-simulate"); - button.disabled = true; - button.title = gatingHint(simulateAccess); - } - if (validateAccess && !validateAccess.allowed) { - const button = card.querySelector(".draft-validate"); - button.disabled = true; - button.title = gatingHint(validateAccess); - } - card.querySelector(".draft-submit").addEventListener("click", async () => { - try { - await submitDraftVersion(draft.world_version_id); - } catch (error) { - const detail = parseErrorDetail(error); - await refreshAuthorSurface(); - if (detail?.code === "author_entitlement_required") { - alertAuthorGating(detail, "提交送审"); - return; - } - alert(`送审失败:${error.message}`); - } - }); - if (submitAccess && !submitAccess.allowed) { - const button = card.querySelector(".draft-submit"); - button.disabled = true; - button.title = gatingHint(submitAccess); - } - card.addEventListener("click", async () => { - appState.activeDraftVersionId = draft.world_version_id; - appState.activeDraftDetail = await api(`/v1/author/drafts/${draft.world_version_id}`); - appState.selectedAuthorRevisionIndex = null; - renderAuthorDrafts(); - renderAuthorReports(); - }); - els.authorDraftList.appendChild(card); - }); -} - -function renderAuthorWorkflow() { - clearNode(els.authorWorkflow); - const workflow = appState.authorWorkflowSummary; - if (!workflow) { - clearNode(els.authorWorkflow, "这里会显示 brief -> draft -> simulate -> revise -> submit 的当前阶段与建议动作。"); - return; - } - const card = document.createElement("article"); - card.className = "list-card"; - card.innerHTML = ` -
-

${workflow.draft_title || "Author Workflow"}

- ${authorStageLabel(workflow.stage)} -
-

world_version ${workflow.world_version_id || "-"}\nrecommended ${workflow.recommended_action || "-"}\nstatus ${workflow.status || "-"}\nvalidation ${workflow.validation_summary?.status || "-"} · errors ${workflow.validation_summary?.error_count ?? 0} · warnings ${workflow.validation_summary?.warning_count ?? 0}\nsimulation ${workflow.simulation_summary?.latest_decision || "-"} · pass ${formatPercent(workflow.simulation_summary?.pass_rate)} · rewrite ${formatPercent(workflow.simulation_summary?.rewrite_rate)} · block ${formatPercent(workflow.simulation_summary?.block_rate)}\nsimulation freshness ${workflow.simulation_freshness?.status || "-"}\n\nstages:\n${(workflow.stages || []).map((item) => `${item.key} · ${item.label} · ${item.status}`).join("\n") || "-"}\n\nblockers:\n${(workflow.blockers || []).map((item) => `${item.key} · ${item.message}`).join("\n") || "-"}

- `; - els.authorWorkflow.appendChild(card); - if ((workflow.cta_actions || []).length) { - const actions = document.createElement("div"); - actions.className = "composer-actions"; - workflow.cta_actions.forEach((item) => { - const button = document.createElement("button"); - button.className = item.primary ? "primary-action" : "ghost-action"; - button.textContent = item.label || item.action_id; - button.disabled = item.enabled === false; - if (item.reason) { - button.title = item.reason; - } - button.addEventListener("click", async () => { - try { - await runAuthorWorkflowAction(item.action_id); - } catch (error) { - alert(`执行工作流动作失败:${error.message}`); - } - }); - actions.appendChild(button); - }); - els.authorWorkflow.appendChild(actions); - } -} - -function renderAuthorReports() { - renderAuthorAuthStatus(); - els.authorActiveDraft.textContent = appState.activeDraftVersionId || "-"; - els.authorValidationStatus.textContent = appState.authorValidationReport?.status || (appState.authorValidationReport?.ok ? "ok" : "未运行"); - els.authorSimulationChapters.textContent = String(appState.authorSimulationReport?.completed_chapters || 0); - const saveDraftAccess = appState.authorAccessSnapshot?.actions?.save_draft || null; - const briefAccess = appState.authorAccessSnapshot?.actions?.draft_from_brief || null; - const simulateAccess = appState.authorAccessSnapshot?.actions?.simulate || null; - if (els.authorBriefAccess) { - els.authorBriefAccess.textContent = gatingStatusLabel(briefAccess); - els.authorBriefAccess.title = gatingHint(briefAccess); - } - if (els.authorSimulateAccess) { - els.authorSimulateAccess.textContent = gatingStatusLabel(simulateAccess); - els.authorSimulateAccess.title = gatingHint(simulateAccess); - } - if (els.authorCreateDraftFromBrief) { - els.authorCreateDraftFromBrief.disabled = Boolean(briefAccess && !briefAccess.allowed); - els.authorCreateDraftFromBrief.title = gatingHint(briefAccess); - } - if (els.authorCreateDraft) { - els.authorCreateDraft.disabled = Boolean(saveDraftAccess && !saveDraftAccess.allowed); - els.authorCreateDraft.title = gatingHint(saveDraftAccess); - } - renderAuthorWorkflow(); - renderAuthorDraftDetail(); - renderAuthorRevisionPanels(); - renderAuthorCompare(); - renderAuthorCollaboration(); - clearNode(els.authorValidationReport); - const validationPayload = appState.authorValidationReport || appState.activeDraftDetail?.validation_report || null; - const validationDrilldown = appState.authorValidationReport?.validation_drilldown || appState.activeDraftDetail?.validation_drilldown || {}; - if (validationPayload) { - const node = document.createElement("article"); - node.className = "list-card"; - const validation = validationPayload; - node.innerHTML = ` -
-

Validation / Submit 结果

- ${validation.status || (validation.ok ? "ok" : "pending")} -
-

ok: ${validation.ok ? "true" : "false"}\nerrors: ${(validation.errors || []).length || 0}\nwarnings: ${(validation.warnings || []).length || 0}\n\nblockers:\n${(validationDrilldown.blockers || []).map((item) => `${item.category} · ${item.severity}\n${item.message}\n建议:${item.recommended_action}`).join("\n\n") || (validation.errors || []).join("\n") || "-"}\n\nwarnings:\n${(validationDrilldown.warning_groups || []).map((item) => `${item.category} · ${item.message}\n建议:${item.recommended_action}`).join("\n\n") || (validation.warnings || []).join("\n") || "-"}\n\nnext actions:\n${(validationDrilldown.next_actions || []).join("\n") || "-"}

- `; - els.authorValidationReport.appendChild(node); - } else { - clearNode(els.authorValidationReport, "选择一个 draft 后,这里会显示 validation report。"); - } - clearNode(els.authorSimulationReport); - const simulationReport = appState.authorSimulationReport || appState.activeDraftDetail?.simulation_report || null; - const simulationDrilldown = getSimulationDrilldown(); - if (simulationReport) { - const topIssues = simulationReport.evaluation_summary?.top_issue_categories || []; - const failingPacks = simulationReport.top_failing_packs || appState.opsCrossPackQuality?.top_failing_packs || []; - const metricDeltas = simulationReport.metric_deltas || {}; - const deltaSummary = simulationReport.cross_pack_summary?.delta_summary || appState.opsCrossPackQuality?.delta_summary || {}; - const currentDiagnosis = simulationReport.cross_pack_summary?.worlds?.find( - (item) => item.world_id === appState.activeDraftDetail?.world_id - ); - const diffSummary = buildSimulationDiffSummary(appState.authorPreviousSimulationReport, simulationReport); - - els.authorSimulationReport.appendChild( - createListCard({ - title: "Simulation 概览", - score: simulationReport.ok ? "ok" : "warn", - body: - `完成章节 ${simulationReport.completed_chapters || 0} / ${simulationDrilldown.chapter_budget || simulationReport.chapter_budget || "-"} · latest ${simulationReport.latest_decision || "-"}\n` + - `completion ${simulationDrilldown.completion_ratio !== undefined ? Number(simulationDrilldown.completion_ratio).toFixed(3) : "-"} · stop ${simulationDrilldown.stop_reason || simulationReport.stop_reason || "-"}\n` + - `pass ${formatPercent(simulationReport.evaluation_summary?.pass_rate)} · rewrite ${formatPercent(simulationReport.evaluation_summary?.rewrite_rate)} · block ${formatPercent(simulationReport.evaluation_summary?.block_rate)}\n` + - `${currentDiagnosis ? `当前 Draft 诊断:${currentDiagnosis.issue_summary?.dominant_issue || "-"} · ${(currentDiagnosis.issue_summary?.weakest_dimensions || []).map((item) => `${item.name}=${Number(item.value || 0).toFixed(3)}`).join(" / ") || "-"}` : "当前 Draft 诊断:-"}\n` + - `${Object.keys(metricDeltas).length ? `指标 delta:${Object.entries(metricDeltas).map(([key, value]) => `${key}=${Number(value).toFixed(3)}`).join(" / ")}` : "指标 delta:-"}\n` + - `${diffSummary ? `与上次 simulation 对比:\n${diffSummary}` : "与上次 simulation 对比:-"}\n` + - `${typeof deltaSummary.cross_pack_pass_rate_delta === "number" ? `cross-pack pass rate delta: ${deltaSummary.cross_pack_pass_rate_delta >= 0 ? "+" : ""}${deltaSummary.cross_pack_pass_rate_delta.toFixed(3)}` : "cross-pack pass rate delta: -"}` - }) - ); - - els.authorSimulationReport.appendChild( - createListCard({ - title: "Issue / Module Drill-down", - score: `${(simulationDrilldown.issue_histogram || []).length} 类`, - body: - `${(simulationDrilldown.issue_histogram || []).length ? `issue histogram:\n${simulationDrilldown.issue_histogram.map((item) => `${item.issue_code} · ${item.count} · ${item.owning_module || "-"}`).join("\n")}` : "issue histogram: -"}\n\n` + - `${(simulationDrilldown.module_histogram || []).length ? `module histogram:\n${simulationDrilldown.module_histogram.map((item) => `${item.owning_module} · ${item.count} · ${(item.issue_codes || []).join("/") || "-"}`).join("\n")}` : "module histogram: -"}\n\n` + - `${Object.keys(simulationDrilldown.decision_histogram || {}).length ? `decision histogram:\n${Object.entries(simulationDrilldown.decision_histogram || {}).map(([key, value]) => `${key}: ${value}`).join("\n")}` : "decision histogram: -"}\n\n` + - `${Object.keys(simulationDrilldown.story_phase_histogram || {}).length ? `story phases:\n${Object.entries(simulationDrilldown.story_phase_histogram || {}).map(([key, value]) => `${key}: ${value}`).join("\n")}` : "story phases: -"}\n\n` + - `${Object.keys(simulationDrilldown.scene_function_histogram || {}).length ? `scene functions:\n${Object.entries(simulationDrilldown.scene_function_histogram || {}).map(([key, value]) => `${key}: ${value}`).join("\n")}` : "scene functions: -"}\n\n` + - `${(simulationDrilldown.next_actions || topIssues || []).length ? `next actions:\n${(simulationDrilldown.next_actions || topIssues || []).map((item, index) => `${index + 1}. ${item.issue_code} -> ${item.owning_module}\n建议:${item.fix_hint}`).join("\n\n")}` : "next actions: -"}\n\n` + - `${simulationDrilldown.quality_pass_summary?.action_histogram?.length ? `quality pass:\nchapters touched ${simulationDrilldown.quality_pass_summary.chapters_touched}\n${simulationDrilldown.quality_pass_summary.action_histogram.map((item) => `${item.action}: ${item.count}`).join("\n")}` : "quality pass: -"}` - }) - ); - - els.authorSimulationReport.appendChild( - createListCard({ - title: "Issue Focus Queue", - score: `${(simulationDrilldown.issue_focus_queue || []).length} 项`, - body: - `${(simulationDrilldown.issue_focus_queue || []).map((item) => `${item.issue_code} · ${item.count} · ${item.owning_module || "-"}\n建议:${item.fix_hint || "-"}\n章节:${(item.chapter_targets || []).map((chapter) => `${chapter.chapter_index}.${chapter.chapter_title}(${chapter.scene_function || "-"}/${chapter.decision || "-"})`).join(" / ") || "-"}`).join("\n\n") || "暂无 issue focus queue。"}` - }) - ); - if ((simulationDrilldown.issue_focus_queue || [])[0]?.chapter_targets?.[0]) { - const queueActions = document.createElement("div"); - queueActions.className = "composer-actions"; - const firstTarget = simulationDrilldown.issue_focus_queue[0].chapter_targets[0]; - const button = document.createElement("button"); - button.className = "ghost-action"; - button.textContent = "评论首个问题章节"; - button.addEventListener("click", () => { - prefillAuthorCommentAnchor("simulation", String(firstTarget.chapter_index)); - }); - queueActions.appendChild(button); - els.authorSimulationReport.appendChild(queueActions); - } - - els.authorSimulationReport.appendChild( - createListCard({ - title: "Weakest Chapters", - score: `${(simulationDrilldown.weakest_chapters || []).length} 章`, - body: - `${(simulationDrilldown.weakest_chapters || []).map((item) => `${item.chapter_index}. ${item.chapter_title || item.chapter_id}\n${item.decision} · score ${Number(item.overall_score || 0).toFixed(3)} · scene ${item.scene_function || "-"}\nissues ${(item.issue_codes || []).join(" / ") || "-"}\nsignals rep ${Number(item.signal_snapshot?.repetition_score || 0).toFixed(3)} · expo ${Number(item.signal_snapshot?.exposition_ratio || 0).toFixed(3)} · hook ${Number(item.signal_snapshot?.hook_quality || 0).toFixed(3)} · detail ${Number(item.signal_snapshot?.concrete_detail_density || 0).toFixed(3)}\nquality pass ${(item.quality_pass_actions || []).join(" / ") || "-"}`).join("\n\n") || "暂无章节级弱项。"}` - }) - ); - - els.authorSimulationReport.appendChild( - createListCard({ - title: "Chapter Drill-down", - score: `${(simulationDrilldown.chapter_breakdown || []).length} 章`, - body: - `${(simulationDrilldown.chapter_breakdown || []).map((item) => `${item.chapter_index}. ${item.chapter_title || item.chapter_id}\n${item.decision} · score ${Number(item.overall_score || 0).toFixed(3)} · scene ${item.scene_function || "-"}\nissues ${(item.issue_codes || []).join(" / ") || "-"}\nchoices ${(item.choices_preview || []).join(" / ") || "-"}\nquality pass ${(item.quality_pass_actions || []).join(" / ") || "-"}\ncritic signals ${item.critic_signal_count ?? 0}`).join("\n\n") || "暂无 chapter breakdown。"}` - }) - ); - } else { - clearNode(els.authorSimulationReport, "运行 simulation 后,这里会显示 route length、reader leak 与 cost estimate。"); - } - const worldpack = getActiveDraftWorldpack() || {}; - const stylePack = worldpack.narrative_style_pack || {}; - const dialogueBundle = { - dialogue_realism_policy: worldpack.dialogue_realism_policy || {}, - voice_profiles: worldpack.voice_profiles || stylePack.dialogue?.voice_profiles || {}, - response_cadence_profiles: worldpack.response_cadence_profiles || stylePack.dialogue?.response_profiles || {}, - pressure_response_styles: worldpack.pressure_response_styles || stylePack.dialogue?.pressure_styles || {}, - }; - els.authorVoiceEditor.value = JSON.stringify(dialogueBundle, null, 2); - els.authorActionEditor.value = JSON.stringify( - worldpack.emotion_action_policies || { default: stylePack.emotion_actions || {} }, - null, - 2 - ); - els.authorSensoryEditor.value = JSON.stringify( - worldpack.sensory_grounding_policies || { default: stylePack.sensory_grounding || {} }, - null, - 2 - ); - els.authorSceneEditor.value = JSON.stringify( - worldpack.scene_realization_contracts || { default: stylePack.scene_realization || {} }, - null, - 2 - ); - renderStylePacingHookControls(); - renderCharacterEditor(); - renderSceneEditor(); -} - -async function refreshAuthorSurface() { - await hydrateAuthorAuthSession(); - if (!appState.authorBriefTemplate) { - try { - appState.authorBriefTemplate = await api("/v1/author/brief-template"); - populateAuthorBriefForm(); - } catch (error) { - console.warn("brief template unavailable", error); - } - } - const payload = await api("/v1/author/drafts"); - appState.authorDrafts = payload.drafts; - if (!appState.activeDraftVersionId && appState.authorDrafts.length) { - appState.activeDraftVersionId = appState.authorDrafts[0].world_version_id; - } - if (appState.activeDraftVersionId) { - try { - appState.activeDraftDetail = await api(`/v1/author/drafts/${appState.activeDraftVersionId}`); - } catch (error) { - appState.activeDraftDetail = null; - appState.activeDraftVersionId = null; - } - } - if (appState.activeDraftVersionId) { - try { - appState.authorCollaborationSummary = await api(`/v1/author/drafts/${appState.activeDraftVersionId}/collaboration`); - const availableThreadIds = new Set((appState.authorCollaborationSummary?.threads || []).map((item) => item.thread_id)); - if (appState.selectedAuthorThreadId && !availableThreadIds.has(appState.selectedAuthorThreadId)) { - appState.selectedAuthorThreadId = null; - } - if (!appState.selectedAuthorThreadId && availableThreadIds.size) { - appState.selectedAuthorThreadId = Array.from(availableThreadIds)[0]; - } - } catch (error) { - appState.authorCollaborationSummary = null; - appState.selectedAuthorThreadId = null; - } - } else { - appState.authorCollaborationSummary = null; - appState.selectedAuthorThreadId = null; - } - if (els.authorApprovalReviewer?.value.trim() && !els.authorInboxReviewerId?.value.trim()) { - els.authorInboxReviewerId.value = els.authorApprovalReviewer.value.trim(); - } - try { - await refreshAuthorReviewerInbox(); - } catch (error) { - appState.authorReviewerInbox = null; - appState.authorReviewerInboxNextCursor = null; - appState.authorReviewerInboxHasMore = false; - } - try { - await refreshAuthorNotificationPreferences(); - } catch (error) { - appState.authorNotificationPreferences = null; - } - if (els.authorAccountId?.value.trim()) { - try { - appState.authorAccessSnapshot = await api( - `/v1/author/access?account_id=${encodeURIComponent(els.authorAccountId.value.trim())}${ - appState.activeDraftVersionId ? `&world_version_id=${encodeURIComponent(appState.activeDraftVersionId)}` : "" - }` - ); - } catch (error) { - appState.authorAccessSnapshot = null; - } - } else { - appState.authorAccessSnapshot = null; - } - try { - const query = new URLSearchParams(); - query.set("account_id", els.authorAccountId?.value.trim() || "web_author"); - if (appState.activeDraftVersionId) { - query.set("world_version_id", appState.activeDraftVersionId); - } - appState.authorWorkflowSummary = await api(`/v1/author/workflow?${query.toString()}`); - if (!appState.activeDraftVersionId && appState.authorWorkflowSummary?.world_version_id) { - appState.activeDraftVersionId = appState.authorWorkflowSummary.world_version_id; - appState.activeDraftDetail = await api(`/v1/author/drafts/${appState.activeDraftVersionId}`); - } - } catch (error) { - appState.authorWorkflowSummary = null; - } - if (els.authorAccountId?.value.trim()) { - try { - const entitlements = await api(`/v1/reader/entitlements?account_id=${encodeURIComponent(els.authorAccountId.value.trim())}`); - els.authorStudioCredits.textContent = String(Number(entitlements.wallets?.studio_credits?.balance || 0).toFixed(0)); - if (els.authorTier) { - els.authorTier.textContent = tierLabel(entitlements.subscription?.tier_id) || entitlements.subscription?.tier_id || "-"; - } - } catch (error) { - els.authorStudioCredits.textContent = "-"; - if (els.authorTier) { - els.authorTier.textContent = "-"; - } - } - } - renderAuthorDrafts(); - renderAuthorReports(); -} - -async function createDraftFromCurrentWorld() { - if (!appState.worldId) { - alert("先选择一个世界。"); - return; - } - try { - const detail = await api(`/v1/library/worlds/${appState.worldId}`); - const pack = detail.worldpack; - pack.version = `${pack.version}-draft-${Date.now()}`; - pack.manifest.author_id = els.authorAccountId?.value.trim() || "web_author"; - const draft = await api("/v1/author/drafts", { - method: "POST", - body: JSON.stringify({ - worldpack: pack, - account_id: els.authorAccountId?.value.trim() || "web_author", - change_context: { source: "manual_update", label: "从当前世界复制" }, - }), - }); - appState.activeDraftVersionId = draft.world_version_id; - appState.activeDraftDetail = await api(`/v1/author/drafts/${draft.world_version_id}`); - appState.selectedAuthorRevisionIndex = null; - appState.authorValidationReport = draft.validation_report; - appState.authorSimulationReport = null; - await refreshAuthorSurface(); - await refreshOpsSurface(); - focusAuthorPanel("draft_detail"); - } catch (error) { - const detail = parseErrorDetail(error); - await refreshAuthorSurface(); - if (detail?.code === "author_entitlement_required") { - alertAuthorGating(detail, "创建 Draft"); - return; - } - alert(`创建 Draft 失败:${error.message}`); - } -} - -async function createDraftFromBrief() { - const brief = buildAuthorBriefPayload(); - if (!brief.world_title || !brief.core_premise) { - alert("请至少填写世界标题和故事 brief。"); - return; - } - try { - const draft = await api("/v1/author/drafts/from-brief", { - method: "POST", - body: JSON.stringify({ brief }), - }); - appState.activeDraftVersionId = draft.world_version_id; - appState.activeDraftDetail = await api(`/v1/author/drafts/${draft.world_version_id}`); - appState.selectedAuthorRevisionIndex = null; - appState.authorValidationReport = draft.validation_report; - appState.authorSimulationReport = null; - await refreshAuthorSurface(); - await refreshOpsSurface(); - focusAuthorPanel("draft_detail"); - } catch (error) { - const detail = parseErrorDetail(error); - await refreshAuthorSurface(); - if (detail?.code === "author_entitlement_required") { - alert(`当前不能创建 Draft:${accessReasonLabel(detail.reason)}。需要 ${detail.required_display_name || tierLabel(detail.required_tier)},当前 ${detail.wallet_type || "-"} 余额 ${Number(detail.balance || 0).toFixed(0)}。`); - return; - } - alert(`生成 Draft 失败:${error.message}`); - } -} - -async function saveCapabilityAssets() { - const activeWorldpack = getActiveDraftWorldpack(); - if (!activeWorldpack || !appState.activeDraftVersionId) { - alert("先选择一个 draft。"); - return; - } - const worldpack = structuredClone(activeWorldpack); - worldpack.narrative_style_pack = worldpack.narrative_style_pack || {}; - try { - const dialogueBundle = JSON.parse(els.authorVoiceEditor.value || "{}"); - worldpack.dialogue_realism_policy = dialogueBundle.dialogue_realism_policy || {}; - worldpack.voice_profiles = dialogueBundle.voice_profiles || {}; - worldpack.response_cadence_profiles = dialogueBundle.response_cadence_profiles || {}; - worldpack.pressure_response_styles = dialogueBundle.pressure_response_styles || {}; - worldpack.emotion_action_policies = JSON.parse(els.authorActionEditor.value || "{}"); - worldpack.sensory_grounding_policies = JSON.parse(els.authorSensoryEditor.value || "{}"); - worldpack.scene_realization_contracts = JSON.parse(els.authorSceneEditor.value || "{}"); - worldpack.narrative_style_pack.dialogue = { - ...worldpack.dialogue_realism_policy, - voice_profiles: worldpack.voice_profiles, - response_profiles: worldpack.response_cadence_profiles, - pressure_styles: worldpack.pressure_response_styles, - }; - applyStylePacingHookControls(worldpack); - worldpack.narrative_style_pack.emotion_actions = Object.values(worldpack.emotion_action_policies)[0] || {}; - worldpack.narrative_style_pack.sensory_grounding = Object.values(worldpack.sensory_grounding_policies)[0] || {}; - worldpack.narrative_style_pack.scene_realization = Object.values(worldpack.scene_realization_contracts)[0] || {}; - } catch (error) { - alert("能力配置 JSON 解析失败,请检查格式。"); - return; - } - try { - const draft = await api(`/v1/author/drafts/${appState.activeDraftVersionId}`, { - method: "PUT", - body: JSON.stringify({ - worldpack, - account_id: els.authorAccountId?.value.trim() || "web_author", - change_context: { source: "capability_editor", label: "保存能力配置" }, - }), - }); - appState.activeDraftVersionId = draft.world_version_id || appState.activeDraftVersionId; - appState.activeDraftDetail = await api(`/v1/author/drafts/${appState.activeDraftVersionId}`); - appState.selectedAuthorRevisionIndex = null; - appState.authorValidationReport = draft.validation_report || appState.activeDraftDetail.validation_report; - await refreshAuthorSurface(); - await refreshOpsSurface(); - focusAuthorPanel("diff"); - } catch (error) { - const detail = parseErrorDetail(error); - await refreshAuthorSurface(); - if (detail?.code === "author_entitlement_required") { - alertAuthorGating(detail, "保存能力配置"); - return; - } - alert(`保存能力配置失败:${error.message}`); - } -} - -async function saveCharacterCard() { - const activeWorldpack = getActiveDraftWorldpack(); - if (!activeWorldpack || !appState.activeDraftVersionId) { - alert("先选择一个 draft。"); - return; - } - const worldpack = structuredClone(activeWorldpack); - const characters = worldpack.characters || []; - if (!characters.length) { - alert("当前 draft 没有可编辑角色。"); - return; - } - const index = Math.min(selectedCharacterIndex(), characters.length - 1); - const character = characters[index]; - character.display_name = els.authorCharacterName.value.trim(); - character.role = els.authorCharacterRole.value.trim() || character.role; - character.destiny_contract = character.destiny_contract || {}; - character.destiny_contract.life_theme = els.authorCharacterLifeTheme.value.trim(); - character.wound_profile = character.wound_profile || {}; - character.wound_profile.core_wound = els.authorCharacterCoreWound.value.trim(); - character.wound_profile.public_self = els.authorCharacterPublicSelf.value.trim(); - character.wound_profile.shadow_desire = els.authorCharacterShadowDesire.value.trim(); - character.vow_profile = character.vow_profile || {}; - character.vow_profile.vows = els.authorCharacterVows.value - .split("\n") - .map((item) => item.trim()) - .filter(Boolean); - - try { - const draft = await api(`/v1/author/drafts/${appState.activeDraftVersionId}`, { - method: "PUT", - body: JSON.stringify({ - worldpack, - account_id: els.authorAccountId?.value.trim() || "web_author", - change_context: { source: "character_editor", label: "保存角色卡" }, - }), - }); - appState.activeDraftDetail = await api(`/v1/author/drafts/${appState.activeDraftVersionId}`); - appState.selectedAuthorRevisionIndex = null; - appState.authorValidationReport = draft.validation_report || appState.activeDraftDetail.validation_report; - await refreshAuthorSurface(); - focusAuthorPanel("diff"); - } catch (error) { - const detail = parseErrorDetail(error); - await refreshAuthorSurface(); - if (detail?.code === "author_entitlement_required") { - alertAuthorGating(detail, "保存角色卡"); - return; - } - alert(`保存角色卡失败:${error.message}`); - } -} - -async function saveSceneBlueprint() { - const activeWorldpack = getActiveDraftWorldpack(); - if (!activeWorldpack || !appState.activeDraftVersionId) { - alert("先选择一个 draft。"); - return; - } - const worldpack = structuredClone(activeWorldpack); - const scenes = worldpack.scene_blueprints || []; - if (!scenes.length) { - alert("当前 draft 没有可编辑场景。"); - return; - } - const index = Math.min(selectedSceneIndex(), scenes.length - 1); - const scene = scenes[index]; - scene.scene_id = els.authorSceneId.value.trim() || scene.scene_id; - scene.scene_function = els.authorSceneFunction.value.trim() || scene.scene_function; - scene.required_roles = els.authorSceneRequiredRoles.value - .split("\n") - .map((item) => item.trim()) - .filter(Boolean); - scene.beats_template = els.authorSceneBeats.value - .split("\n") - .map((item) => item.trim()) - .filter(Boolean); - - try { - const draft = await api(`/v1/author/drafts/${appState.activeDraftVersionId}`, { - method: "PUT", - body: JSON.stringify({ - worldpack, - account_id: els.authorAccountId?.value.trim() || "web_author", - change_context: { source: "scene_editor", label: "保存场景蓝图" }, - }), - }); - appState.activeDraftDetail = await api(`/v1/author/drafts/${appState.activeDraftVersionId}`); - appState.selectedAuthorRevisionIndex = null; - appState.authorValidationReport = draft.validation_report || appState.activeDraftDetail.validation_report; - await refreshAuthorSurface(); - focusAuthorPanel("diff"); - } catch (error) { - const detail = parseErrorDetail(error); - await refreshAuthorSurface(); - if (detail?.code === "author_entitlement_required") { - alertAuthorGating(detail, "保存场景蓝图"); - return; - } - alert(`保存场景蓝图失败:${error.message}`); - } -} - -async function loadExampleBundle(exampleId) { - appState.currentBundle = await api(`/v1/examples/${exampleId}`); - appState.worldId = appState.currentBundle.world_bible.world_id; - appState.selectedIntentOverride = null; - updateBundleSummary(); - renderWorldGallery(); - renderSuggestedInputs(); - await refreshSessionLibrary(); - await refreshReaderEntitlements(); - updateStatus(); -} - -async function refreshExamples() { - const payload = await api("/v1/examples"); - appState.examples = payload.examples; - const shelfPayload = await api("/v1/library/worlds"); - appState.shelfWorlds = shelfPayload.worlds; - const selected = appState.examples.find((item) => item.example_id === "demo") || appState.examples[0]; - if (selected) { - await loadExampleBundle(selected.example_id); - } -} - -async function refreshSessionLibrary() { - if (!appState.currentBundle) { - appState.sessionLibrary = []; - renderSessionLibrary(); - return; - } - const payload = await api(`/v1/sessions?world_id=${encodeURIComponent(appState.currentBundle.world_bible.world_id)}`); - appState.sessionLibrary = payload.sessions; - renderSessionLibrary(); -} - -async function bootstrapWorld(triggerButton = null) { - if (!appState.currentBundle) return; - const restore = triggerButton ? setBusy(triggerButton, "进入中…") : () => {}; - try { - const worldPayload = { - world_bible: appState.currentBundle.world_bible, - event_atoms: appState.currentBundle.event_atoms, - metadata: { source: "frontend_bootstrap" }, - }; - const worldResult = await api("/v1/worlds", { - method: "POST", - body: JSON.stringify(worldPayload), - }); - const sessionResult = await api("/v1/sessions", { - method: "POST", - body: JSON.stringify({ - world_id: worldResult.world_id, - initial_state: appState.currentBundle.initial_state, - player_profile: { surface: "app", reader_id: activeReaderId() }, - metadata: { reader_id: activeReaderId() }, - }), - }); - - appState.worldId = worldResult.world_id; - appState.worldVersionId = sessionResult.world_version_id || null; - appState.sessionPaywall = sessionResult.paywall || null; - appState.sessionId = sessionResult.session_id; - appState.currentState = sessionResult.current_state; - appState.intentPrefill = { - last_player_intent: "", - current_pressure: "故事刚刚开始。", - suggested_prefill: "我想先试探眼前这条路到底会把我带到哪一边。", - }; - appState.latestStep = null; - appState.latestPreview = null; - appState.replay = null; - appState.selectedReplayIndex = null; - - await refreshSessionLibrary(); - await refreshReaderEntitlements(); - updateStatus(); - renderRoutePreview(); - renderLatestStep(); - renderReplay(); - } catch (error) { - alert(`开始旅程失败:${error.message}`); - } finally { - restore(); - } -} - -async function restoreSession(sessionId, triggerButton = null) { - if (!sessionId) return; - const restore = triggerButton ? setBusy(triggerButton, "回到这一幕…") : () => {}; - try { - const sessionPayload = await api(`/v1/sessions/${sessionId}`); - const replayPayload = await api(`/v1/sessions/${sessionId}/replay`); - appState.sessionId = sessionId; - appState.currentState = sessionPayload.session.current_state; - appState.sessionPaywall = sessionPayload.paywall || null; - appState.latestStep = sessionPayload.latest_step; - appState.replay = replayPayload; - appState.worldId = sessionPayload.session.world_id; - appState.worldVersionId = sessionPayload.world_version_id || sessionPayload.session.metadata?.world_version_id || null; - appState.readerId = sessionPayload.session.metadata?.reader_id || appState.readerId; - appState.intentPrefill = sessionPayload.intent_prefill || (await api(`/v1/sessions/${sessionId}/prefill`)); - appState.selectedReplayIndex = replayPayload.event_trace.length - ? replayPayload.event_trace.length - 1 - : null; - appState.activeView = "experience"; - syncViewMode(); - renderSessionLibrary(); - await refreshReaderEntitlements(); - updateStatus(); - renderLatestStep(); - renderReplay(); - spotlightChapter(); - } catch (error) { - alert(`继续旅程失败:${error.message}`); - } finally { - restore(); - } -} - -async function deleteSession(sessionId) { - if (!sessionId) return; - const confirmed = window.confirm("删除后这段旅程会从书架中移除,确定继续吗?"); - if (!confirmed) return; - try { - await api(`/v1/sessions/${sessionId}`, { method: "DELETE" }); - if (appState.sessionId === sessionId) { - appState.sessionId = null; - appState.currentState = null; - appState.latestStep = null; - appState.latestPreview = null; - appState.replay = null; - appState.selectedReplayIndex = null; - appState.activeView = "experience"; - syncViewMode(); - renderRoutePreview(); - renderLatestStep(); - renderReplay(); - } - await refreshSessionLibrary(); - updateStatus(); - } catch (error) { - alert(`删除失败:${error.message}`); - } -} - -async function previewRoute() { - if (!appState.currentBundle || !appState.currentState) return; - const restore = setBusy(els.previewRoute, "预览中…"); - try { - const previewState = - typeof structuredClone === "function" - ? structuredClone(appState.currentState) - : JSON.parse(JSON.stringify(appState.currentState)); - if (appState.selectedIntentOverride) { - previewState.player_intent = appState.selectedIntentOverride; - } - appState.latestPreview = await api("/v1/routes/preview", { - method: "POST", - body: JSON.stringify({ - world: appState.currentBundle.world_bible, - state: previewState, - candidate_events: appState.currentBundle.event_atoms, - beam_width: 3, - depth: 2, - }), - }); - renderRoutePreview(); - spotlightPreviewResult(); - } catch (error) { - alert(`没能看到下一步:${error.message}`); - } finally { - restore(); - } -} - -async function stepSession() { - if (!appState.sessionId) return; - const playerInput = els.playerInput.value.trim(); - if (!playerInput) { - alert("先写下一句你现在真正想做的事。"); - return; - } - const restore = setBusy(els.stepSession, "执行中…"); - try { - appState.latestStep = await api(`/v1/sessions/${appState.sessionId}/step?debug=true`, { - method: "POST", - body: JSON.stringify({ - player_input: playerInput, - intent_override: appState.selectedIntentOverride, - beam_width: 3, - depth: 2, - metadata: { reader_id: activeReaderId() }, - }), - }); - appState.currentState = appState.latestStep.updated_state; - appState.worldVersionId = appState.latestStep.world_version_id || appState.worldVersionId; - appState.sessionPaywall = appState.latestStep.paywall || appState.sessionPaywall; - appState.replay = await api(`/v1/sessions/${appState.sessionId}/replay`); - appState.intentPrefill = await api(`/v1/sessions/${appState.sessionId}/prefill`); - appState.selectedReplayIndex = appState.replay.event_trace.length - ? appState.replay.event_trace.length - 1 - : null; - await refreshSessionLibrary(); - await refreshReaderEntitlements(); - updateStatus(); - renderLatestStep(); - renderReplay(); - } catch (error) { - alert(`这一幕没能推进:${error.message}`); - } finally { - restore(); - } -} - -function resetOutput() { - appState.latestStep = null; - appState.latestPreview = null; - appState.replay = null; - appState.intentPrefill = null; - appState.selectedReplayIndex = null; - els.playerInput.value = ""; - renderRoutePreview(); - renderLatestStep(); - renderReplay(); -} - -async function bootstrapHealth() { - try { - const payload = await api("/health", { headers: {} }); - els.apiStatus.textContent = payload.status === "ok" ? "在线" : "异常"; - } catch (error) { - els.apiStatus.textContent = "离线"; - } -} - -els.previewRoute.addEventListener("click", previewRoute); -els.stepSession.addEventListener("click", stepSession); -els.resetOutput.addEventListener("click", resetOutput); -els.playerInput.addEventListener("input", () => { - appState.selectedIntentOverride = null; -}); -els.viewExperience.addEventListener("click", () => { - appState.activeView = "experience"; - syncViewMode(); -}); -els.viewStorybook.addEventListener("click", () => { - appState.activeView = "storybook"; - syncViewMode(); - renderStorybook(); -}); -els.viewBackstage.addEventListener("click", () => { - appState.activeView = "backstage"; - syncViewMode(); -}); -els.modeReader.addEventListener("click", () => { - appState.activeProduct = "reader"; - syncProductMode(); -}); -els.modeAuthor.addEventListener("click", async () => { - appState.activeProduct = "author"; - syncProductMode(); - await refreshAuthorSurface(); -}); -els.modeOps.addEventListener("click", async () => { - appState.activeProduct = "ops"; - syncProductMode(); - await refreshOpsSurface(); -}); -els.readerRefreshEntitlements?.addEventListener("click", refreshReaderEntitlements); -els.readerGrantEntitlement?.addEventListener("click", grantReaderEntitlement); -els.readerStartCheckout?.addEventListener("click", () => startReaderCheckout()); -els.readerRetryPayment?.addEventListener("click", retryReaderSubscriptionPayment); -els.readerRenewSubscription?.addEventListener("click", renewReaderSubscription); -els.readerCancelSubscription?.addEventListener("click", cancelReaderSubscription); -els.authorGenrePreset?.addEventListener("change", applyAuthorPresetDefaults); -els.authorCharacterSelect?.addEventListener("change", renderCharacterEditor); -els.authorSceneSelect?.addEventListener("change", renderSceneEditor); -els.authorCreateDraft?.addEventListener("click", createDraftFromCurrentWorld); -els.authorCreateDraftFromBrief?.addEventListener("click", createDraftFromBrief); -els.authorRefresh?.addEventListener("click", refreshAuthorSurface); -els.authorAuthRegister?.addEventListener("click", registerAuthorAuthIdentity); -els.authorAuthLogin?.addEventListener("click", loginAuthorAuthIdentity); -els.authorAuthLogout?.addEventListener("click", logoutAuthorAuthIdentity); -els.authorSaveStyleControls?.addEventListener("click", saveCapabilityAssets); -els.authorSaveCharacter?.addEventListener("click", saveCharacterCard); -els.authorSaveScene?.addEventListener("click", saveSceneBlueprint); -els.authorSaveCapabilities?.addEventListener("click", saveCapabilityAssets); -els.authorRefreshReviewerInbox?.addEventListener("click", async () => { - await refreshAuthorReviewerInbox(); - renderAuthorReports(); -}); -els.authorSearchReviewerInbox?.addEventListener("click", async () => { - await refreshAuthorReviewerInbox(); - renderAuthorReports(); -}); -els.authorLoadMoreReviewerInbox?.addEventListener("click", async () => { - if (!appState.authorReviewerInboxNextCursor) return; - await refreshAuthorReviewerInbox({ append: true, cursor: appState.authorReviewerInboxNextCursor }); - renderAuthorReports(); -}); -els.authorInboxReviewerId?.addEventListener("change", async () => { - await refreshAuthorReviewerInbox(); - renderAuthorReports(); -}); -els.authorInboxStatusFilter?.addEventListener("change", async () => { - await refreshAuthorReviewerInbox(); - renderAuthorReports(); -}); -els.authorInboxWorldVersionFilter?.addEventListener("change", async () => { - await refreshAuthorReviewerInbox(); - renderAuthorReports(); -}); -els.authorInboxNotificationTypeFilter?.addEventListener("change", async () => { - await refreshAuthorReviewerInbox(); - renderAuthorReports(); -}); -els.authorInboxBlockingOnly?.addEventListener("change", async () => { - await refreshAuthorReviewerInbox(); - renderAuthorReports(); -}); -els.authorInboxSearch?.addEventListener("keydown", async (event) => { - if (event.key !== "Enter") return; - await refreshAuthorReviewerInbox(); - renderAuthorReports(); -}); -els.authorBulkReadVisible?.addEventListener("click", async () => { - await bulkUpdateAuthorNotificationStatus("read"); -}); -els.authorBulkArchiveVisible?.addEventListener("click", async () => { - await bulkUpdateAuthorNotificationStatus("archived"); -}); -els.authorAddDraftWatcher?.addEventListener("click", addAuthorDraftWatcher); -els.authorRemoveDraftWatcher?.addEventListener("click", removeAuthorDraftWatcher); -els.authorRefreshNotificationPreferences?.addEventListener("click", async () => { - await refreshAuthorNotificationPreferences(); - renderAuthorReports(); -}); -els.authorSaveNotificationPreference?.addEventListener("click", saveAuthorNotificationPreference); -els.authorNotificationPrefType?.addEventListener("change", () => { - syncAuthorNotificationPreferenceInputs(); -}); -els.authorAccountId?.addEventListener("change", () => { - if (els.authorAuthActorId && !els.authorAuthActorId.value.trim()) { - els.authorAuthActorId.value = els.authorAccountId.value.trim(); - } -}); -els.opsAccountId?.addEventListener("change", () => { - if (els.opsInvestigationAccountId && !els.opsInvestigationAccountId.value.trim()) { - els.opsInvestigationAccountId.value = els.opsAccountId.value.trim(); - } - if (els.opsAlertAccountId && !els.opsAlertAccountId.value.trim()) { - els.opsAlertAccountId.value = els.opsAccountId.value.trim(); - } - if (els.opsNavAccountId && !els.opsNavAccountId.value.trim()) { - els.opsNavAccountId.value = els.opsAccountId.value.trim(); - } -}); -els.authorApprovalReviewer?.addEventListener("change", () => { - if (els.authorInboxReviewerId && !els.authorInboxReviewerId.value.trim()) { - els.authorInboxReviewerId.value = els.authorApprovalReviewer.value.trim(); - } -}); -els.opsAlertAccountId?.addEventListener("change", async () => { - if (els.opsNavAccountId && !els.opsNavAccountId.value.trim()) { - els.opsNavAccountId.value = els.opsAlertAccountId.value.trim(); - } - await refreshOpsAlerts(); - renderOpsSurface(); -}); -els.opsAlertStatusFilter?.addEventListener("change", async () => { - await refreshOpsAlerts(); - renderOpsSurface(); -}); -els.opsAlertSeverityFilter?.addEventListener("change", async () => { - await refreshOpsAlerts(); - renderOpsSurface(); -}); -els.authorCreateCommentThread?.addEventListener("click", createAuthorCommentThread); -els.authorRequestApproval?.addEventListener("click", requestAuthorApproval); -els.authorApproveDraft?.addEventListener("click", () => decideAuthorApproval("approved")); -els.authorRequestChanges?.addEventListener("click", () => decideAuthorApproval("changes_requested")); -els.opsRefresh?.addEventListener("click", refreshOpsSurface); -els.opsSyncNavigation?.addEventListener("click", async () => { - try { - syncOpsNavigationContext(currentOpsNavigationContext(), { preserveExisting: false }); - await refreshOpsSurface({ scopes: ["account", "review_release", "alerts", "navigation"] }); - } catch (error) { - alert(`同步 Ops context 失败:${error.message}`); - } -}); -els.opsFollowRecommendation?.addEventListener("click", async () => { - try { - await followOpsNavigationRecommendation(); - } catch (error) { - alert(`执行推荐升级路径失败:${error.message}`); - } -}); -els.opsNavAccountId?.addEventListener("change", () => { - if (els.opsAccountId) { - els.opsAccountId.value = els.opsNavAccountId.value.trim(); - } -}); -els.opsNavWorldId?.addEventListener("change", () => { - appState.selectedOpsWorldId = (els.opsNavWorldId?.value || "").trim() || null; - if (els.opsReleaseWorldId) { - els.opsReleaseWorldId.value = els.opsNavWorldId.value.trim(); - } -}); -els.opsNavCaseId?.addEventListener("change", () => { - if (els.opsGovernanceCaseId) { - els.opsGovernanceCaseId.value = els.opsNavCaseId.value.trim(); - } -}); -els.opsRefreshReleaseWorkspace?.addEventListener("click", async () => { - try { - await refreshOpsReleaseWorkspace(); - renderOpsSurface(); - } catch (error) { - alert(`刷新 release workspace 失败:${error.message}`); - } -}); -els.opsReleaseWorldId?.addEventListener("change", async () => { - appState.selectedOpsWorldId = (els.opsReleaseWorldId?.value || "").trim() || null; - if (els.opsNavWorldId) { - els.opsNavWorldId.value = els.opsReleaseWorldId.value.trim(); - } - await refreshOpsReleaseWorkspace(); - renderOpsSurface(); -}); -els.opsCreateRuntimeBackup?.addEventListener("click", createRuntimeBackup); -els.opsRestoreRuntimeBackup?.addEventListener("click", restoreRuntimeBackup); -els.opsRunRecoveryDrill?.addEventListener("click", runRecoveryDrill); -els.opsRequestRuntimeRestore?.addEventListener("click", requestRuntimeRestore); -els.opsApproveRuntimeRestore?.addEventListener("click", approveRuntimeRestore); -els.opsRevokeRuntimeRestore?.addEventListener("click", revokeRuntimeRestore); -els.opsExecuteRuntimeRestore?.addEventListener("click", executeRuntimeRestore); -els.opsRunDataIntegrityDryRun?.addEventListener("click", () => runDataIntegrityRepair(false)); -els.opsApplyDataIntegrityRepair?.addEventListener("click", () => runDataIntegrityRepair(true)); -els.opsRetryAsyncJob?.addEventListener("click", retryAsyncJob); -els.opsResumeAsyncJob?.addEventListener("click", resumeAsyncJob); -els.opsRecoverAsyncJobs?.addEventListener("click", recoverAsyncJobIncidents); -els.opsEnforceAsyncRetention?.addEventListener("click", enforceAsyncJobRetention); -els.opsRunColdStartDrill?.addEventListener("click", runColdStartRecoveryDrill); -els.opsExportHandoffBundle?.addEventListener("click", exportAsyncJobHandoffBundle); -els.opsAcknowledgeAsyncJob?.addEventListener("click", acknowledgeAsyncJob); -els.opsShipRemoteArtifacts?.addEventListener("click", shipRemoteArtifacts); -els.opsEscalateHandoffSla?.addEventListener("click", escalateHandoffSla); -els.opsEnqueueNotificationRetry?.addEventListener("click", enqueueNotificationRetry); -els.opsProcessNotificationRetry?.addEventListener("click", processNotificationRetry); -els.opsGrantSubscription?.addEventListener("click", grantOpsSubscription); -els.opsChangeSubscriptionState?.addEventListener("click", changeOpsSubscriptionState); -els.opsGrantWallet?.addEventListener("click", grantOpsWallet); -els.opsDebitWallet?.addEventListener("click", debitOpsWallet); -els.opsRevokeEntitlement?.addEventListener("click", revokeOpsEntitlement); -els.opsReconcileSubscription?.addEventListener("click", reconcileOpsSubscription); -els.opsRetrySubscriptionPayment?.addEventListener("click", retryOpsSubscriptionPayment); -els.opsReplayBillingEvent?.addEventListener("click", replayOpsBillingEvent); -els.opsRefreshAlerts?.addEventListener("click", async () => { - try { - await refreshOpsAlerts(); - renderOpsSurface(); - } catch (error) { - alert(`刷新 alerts 失败:${error.message}`); - } -}); -els.opsAcknowledgeAlert?.addEventListener("click", async () => { - try { - await updateSelectedOpsAlertStatus("acknowledged"); - } catch (error) { - alert(`ack alert 失败:${error.message}`); - } -}); -els.opsResolveAlert?.addEventListener("click", async () => { - try { - await updateSelectedOpsAlertStatus("resolved"); - } catch (error) { - alert(`resolve alert 失败:${error.message}`); - } -}); -els.opsProviderCandidateCanary?.addEventListener("click", () => submitProviderRollout("candidate", "canary")); -els.opsProviderCandidateActivate?.addEventListener("click", () => submitProviderRollout("candidate", "activate")); -els.opsProviderCandidateRollback?.addEventListener("click", () => submitProviderRollout("candidate", "rollback")); -els.opsProviderRendererCanary?.addEventListener("click", () => submitProviderRollout("renderer", "canary")); -els.opsProviderRendererActivate?.addEventListener("click", () => submitProviderRollout("renderer", "activate")); -els.opsProviderRendererRollback?.addEventListener("click", () => submitProviderRollout("renderer", "rollback")); -els.opsOpenAlertInvestigation?.addEventListener("click", async () => { - try { - await openSelectedOpsAlertInvestigation(); - } catch (error) { - alert(`打开 alert investigation 失败:${error.message}`); - } -}); -els.opsRunInvestigation?.addEventListener("click", async () => { - try { - await runOpsInvestigation(); - } catch (error) { - alert(`运行统一排查失败:${error.message}`); - } -}); -els.opsExportInvestigationTrace?.addEventListener("click", async () => { - try { - await exportOpsInvestigationTrace(); - } catch (error) { - alert(`导出 investigation trace 失败:${error.message}`); - } -}); -els.opsCreateGovernanceCase?.addEventListener("click", createGovernanceCase); -els.opsAssignGovernanceCase?.addEventListener("click", assignGovernanceCase); -els.opsAddGovernanceEvidence?.addEventListener("click", addGovernanceEvidence); -els.opsUpdateGovernanceCase?.addEventListener("click", updateGovernanceCaseStatus); -els.opsApplyGovernanceRestriction?.addEventListener("click", applyGovernanceRestriction); -els.opsReleaseGovernanceRestriction?.addEventListener("click", releaseGovernanceRestriction); -els.opsExportGovernanceAudit?.addEventListener("click", refreshGovernanceAuditExport); -els.opsSubmitReviewCapture?.addEventListener("click", submitOpsReviewCapture); -els.opsSubmitPreferenceCapture?.addEventListener("click", submitOpsPreferenceCapture); -els.opsSubmitRankingCapture?.addEventListener("click", submitOpsRankingCapture); -els.opsSubmitPreferenceCapture?.addEventListener("click", submitOpsPreferenceCapture); -els.opsSubmitRankingCapture?.addEventListener("click", submitOpsRankingCapture); -els.opsApprovePromotion?.addEventListener("click", () => submitPromotionDecision("approve")); -els.opsRevokePromotion?.addEventListener("click", () => submitPromotionDecision("revoke")); -els.opsApproveRerankerPromotion?.addEventListener("click", () => submitRerankerPromotionDecision("approve")); -els.opsRevokeRerankerPromotion?.addEventListener("click", () => submitRerankerPromotionDecision("revoke")); -els.opsSetAssistedShadow?.addEventListener("click", () => submitAssistedGateConfig("shadow_only", true)); -els.opsSetAssistedActive?.addEventListener("click", () => submitAssistedGateConfig("assisted_gate", true)); -els.opsDisableAssistedGate?.addEventListener("click", () => submitAssistedGateConfig("shadow_only", false)); -els.opsSetAssistedRerankShadow?.addEventListener("click", () => submitAssistedRerankConfig("shadow_only", true)); -els.opsSetAssistedRerankActive?.addEventListener("click", () => submitAssistedRerankConfig("assisted_rerank", true)); -els.opsDisableAssistedRerank?.addEventListener("click", () => submitAssistedRerankConfig("shadow_only", false)); -els.opsRunEvaluatorTraining?.addEventListener("click", () => runLearnedTraining(["evaluator"])); -els.opsRunRerankerTraining?.addEventListener("click", () => runLearnedTraining(["reranker"])); -els.opsRunBothTraining?.addEventListener("click", () => runLearnedTraining(["evaluator", "reranker"])); -for (const pill of els.tonePills) { - pill.addEventListener("click", () => setTone(pill.dataset.tone)); -} - -restoreAuthorAuthSession(); -renderAuthorAuthStatus(); -bootstrapHealth(); -if (els.readerIdInput) { - els.readerIdInput.value = appState.readerId; -} -syncProductMode(); -syncViewMode(); -updateStatus(); -renderLatestStep(); -renderRoutePreview(); -renderReplay(); -renderIntentPrefill(); -refreshExamples(); -els.readerIdInput?.addEventListener("change", refreshReaderEntitlements); diff --git a/src/narrativeos/web/author_accessors.js b/src/narrativeos/web/author_accessors.js new file mode 100644 index 0000000..e5747ae --- /dev/null +++ b/src/narrativeos/web/author_accessors.js @@ -0,0 +1,100 @@ +// Author draft/workbench accessors shared across author runtime and shell helpers. + +var AuthorAccessors = (() => { + const { reportUiMessage } = UIShared; + const { tierLabel, accessReasonLabel } = ReaderAccessors; + + function getActiveDraftWorldpack() { + return authorState.activeDraftDetail?.worldpack_json || authorState.activeDraftDetail?.worldpack || null; + } + + function getActiveRevisionHistory() { + return authorState.activeDraftDetail?.revision_history || getActiveDraftWorldpack()?.metadata?.revision_history || []; + } + + function getLatestDiffSummary() { + return authorState.activeDraftDetail?.latest_diff_summary || getActiveDraftWorldpack()?.metadata?.latest_diff_summary || {}; + } + + function getDiffDrilldown() { + return authorState.activeDraftDetail?.diff_drilldown || {}; + } + + function getSimulationDrilldown() { + return ( + authorState.authorSimulationReport?.simulation_drilldown || + authorState.activeDraftDetail?.simulation_drilldown || + {} + ); + } + + function getLongformDrilldown() { + return ( + authorState.authorSimulationReport?.longform_drilldown || + authorState.activeDraftDetail?.longform_drilldown || + {} + ); + } + + function getPromiseLedgerWorkbench() { + return authorState.activeDraftDetail?.promise_ledger_workbench || {}; + } + + function getPromiseStateWorkbench() { + return authorState.activeDraftDetail?.promise_state_workbench || {}; + } + + function getSeriesVolumeArcPromiseMapping() { + return authorState.activeDraftDetail?.series_volume_arc_promise_mapping || {}; + } + + function getChapterTaskSimulationLinking() { + return authorState.activeDraftDetail?.chapter_task_simulation_linking || {}; + } + + function getContinuityDiffWorkbench() { + return authorState.activeDraftDetail?.continuity_diff_workbench || {}; + } + + function getContinuityOverrideWorkbench() { + return authorState.activeDraftDetail?.continuity_override_workbench || {}; + } + + function getSimulationDiffCheckpoint() { + return authorState.activeDraftDetail?.simulation_diff_checkpoint || {}; + } + + function selectedAuthorChapterMarker(chapterIndex) { + return Number(authorState.selectedAuthorSimulationChapterIndex || 0) === Number(chapterIndex || 0) ? ">> " : ""; + } + + function selectedAuthorCompareMarker(chapterIndex) { + return Number(authorState.selectedAuthorContinuityChapterIndex || 0) === Number(chapterIndex || 0) ? ">> " : ""; + } + + function alertAuthorGating(errorDetail, actionLabel) { + reportUiMessage( + `当前不能${actionLabel}:${accessReasonLabel(errorDetail.reason)}。需要 ${errorDetail.required_display_name || tierLabel(errorDetail.required_tier)},当前 ${errorDetail.wallet_type || "-"} 余额 ${Number(errorDetail.balance || 0).toFixed(0)}${errorDetail.required_units !== undefined ? ` / 需要 ${Number(errorDetail.required_units).toFixed(0)}` : ""}。`, + "warning" + ); + } + + return { + getActiveDraftWorldpack, + getActiveRevisionHistory, + getLatestDiffSummary, + getDiffDrilldown, + getSimulationDrilldown, + getLongformDrilldown, + getPromiseLedgerWorkbench, + getPromiseStateWorkbench, + getSeriesVolumeArcPromiseMapping, + getChapterTaskSimulationLinking, + getContinuityDiffWorkbench, + getContinuityOverrideWorkbench, + getSimulationDiffCheckpoint, + selectedAuthorChapterMarker, + selectedAuthorCompareMarker, + alertAuthorGating, + }; +})(); diff --git a/src/narrativeos/web/author_dom.js b/src/narrativeos/web/author_dom.js new file mode 100644 index 0000000..54b7c3f --- /dev/null +++ b/src/narrativeos/web/author_dom.js @@ -0,0 +1,193 @@ +// Author-scoped DOM registry. + +var AuthorDOM = (() => ({ + authorCreateDraft: DOMShared.query("#author-create-draft"), + authorCreateDraftFromBrief: DOMShared.query("#author-create-draft-from-brief"), + authorRefresh: DOMShared.query("#author-refresh"), + authorAccountId: DOMShared.query("#author-account-id"), + authorAuthActorId: DOMShared.query("#author-auth-actor-id"), + authorAuthRole: DOMShared.query("#author-auth-role"), + authorAuthDisplayName: DOMShared.query("#author-auth-display-name"), + authorAuthPassword: DOMShared.query("#author-auth-password"), + authorAuthRegister: DOMShared.query("#author-auth-register"), + authorAuthLogin: DOMShared.query("#author-auth-login"), + authorAuthLogout: DOMShared.query("#author-auth-logout"), + authorAuthStatus: DOMShared.query("#author-auth-status"), + authorActiveDraft: DOMShared.query("#author-active-draft"), + authorValidationStatus: DOMShared.query("#author-validation-status"), + authorSimulationChapters: DOMShared.query("#author-simulation-chapters"), + authorTier: DOMShared.query("#author-tier"), + authorStudioCredits: DOMShared.query("#author-studio-credits"), + authorBriefAccess: DOMShared.query("#author-brief-access"), + authorSimulateAccess: DOMShared.query("#author-simulate-access"), + authorStageTitle: DOMShared.query("#author-stage-title"), + authorStageCopy: DOMShared.query("#author-stage-copy"), + authorGuidedFocus: DOMShared.query("#author-guided-focus"), + authorWorkflow: DOMShared.query("#author-workflow"), + authorGenrePreset: DOMShared.query("#author-genre-preset"), + authorWorldTitle: DOMShared.query("#author-world-title"), + authorLeadName: DOMShared.query("#author-lead-name"), + authorCounterpartName: DOMShared.query("#author-counterpart-name"), + authorSupportingName: DOMShared.query("#author-supporting-name"), + authorLifeTheme: DOMShared.query("#author-life-theme"), + authorCorePremise: DOMShared.query("#author-core-premise"), + authorLocations: DOMShared.query("#author-locations"), + authorDraftList: DOMShared.query("#author-draft-list"), + authorDraftDetail: DOMShared.query("#author-draft-detail"), + authorValidationReport: DOMShared.query("#author-validation-report"), + authorSimulationReport: DOMShared.query("#author-simulation-report"), + authorSimulateSummary: DOMShared.query("#author-simulate-summary"), + authorSteeringComposer: DOMShared.query("#author-steering-composer"), + authorSteeringStatus: DOMShared.query("#author-steering-status"), + authorSteeringIntent: DOMShared.query("#author-steering-intent"), + authorSteeringType: DOMShared.query("#author-steering-type"), + authorSteeringCharacters: DOMShared.query("#author-steering-characters"), + authorSteeringMemoryPatch: DOMShared.query("#author-steering-memory-patch"), + authorSteeringArc: DOMShared.query("#author-steering-arc"), + authorRunSteeredSimulation: DOMShared.query("#author-run-steered-simulation"), + authorClearSteering: DOMShared.query("#author-clear-steering"), + authorCreativeCockpit: DOMShared.query("#author-creative-cockpit"), + authorAssetDiff: DOMShared.query("#author-asset-diff"), + authorReviewSummary: DOMShared.query("#author-review-summary"), + authorCompare: DOMShared.query("#author-compare"), + authorVersionHistory: DOMShared.query("#author-version-history"), + authorPromiseLedger: DOMShared.query("#author-promise-ledger"), + authorPromiseSelect: DOMShared.query("#author-promise-select"), + authorPromiseState: DOMShared.query("#author-promise-state"), + authorPromiseNotes: DOMShared.query("#author-promise-notes"), + authorSavePromiseState: DOMShared.query("#author-save-promise-state"), + authorJumpPromiseChapter: DOMShared.query("#author-jump-promise-chapter"), + authorCommentPromise: DOMShared.query("#author-comment-promise"), + authorPromiseMapping: DOMShared.query("#author-promise-mapping"), + authorTaskSimulationLinking: DOMShared.query("#author-task-simulation-linking"), + authorContinuityDiff: DOMShared.query("#author-continuity-diff"), + authorContinuityChapterSelect: DOMShared.query("#author-continuity-chapter-select"), + authorContinuityOverrideState: DOMShared.query("#author-continuity-override-state"), + authorContinuityIssueScope: DOMShared.query("#author-continuity-issue-scope"), + authorContinuityOverrideNotes: DOMShared.query("#author-continuity-override-notes"), + authorSaveContinuityOverride: DOMShared.query("#author-save-continuity-override"), + authorJumpCompareChapter: DOMShared.query("#author-jump-compare-chapter"), + authorCommentContinuity: DOMShared.query("#author-comment-continuity"), + authorSimulationDiffCheckpoint: DOMShared.query("#author-simulation-diff-checkpoint"), + authorRunCheckpointResimulate: DOMShared.query("#author-run-checkpoint-resimulate"), + authorCollaboration: DOMShared.query("#author-collaboration"), + authorReviewerInbox: DOMShared.query("#author-reviewer-inbox"), + authorSettingsSummary: DOMShared.query("#author-settings-summary"), + authorDraftSectionNav: DOMShared.query("#author-draft-section-nav"), + authorDraftSectionSummary: DOMShared.query("#author-draft-section-summary"), + authorCharacterSummary: DOMShared.query("#author-character-summary"), + authorCharacterSelect: DOMShared.query("#author-character-select"), + authorCharacterName: DOMShared.query("#author-character-name"), + authorCharacterRole: DOMShared.query("#author-character-role"), + authorCharacterLifeTheme: DOMShared.query("#author-character-life-theme"), + authorCharacterCoreWound: DOMShared.query("#author-character-core-wound"), + authorCharacterPublicSelf: DOMShared.query("#author-character-public-self"), + authorCharacterShadowDesire: DOMShared.query("#author-character-shadow-desire"), + authorCharacterVows: DOMShared.query("#author-character-vows"), + authorSaveCharacter: DOMShared.query("#author-save-character"), + authorSceneSelect: DOMShared.query("#author-scene-select"), + authorSceneId: DOMShared.query("#author-scene-id"), + authorSceneFunction: DOMShared.query("#author-scene-function"), + authorSceneRequiredRoles: DOMShared.query("#author-scene-required-roles"), + authorSceneBeats: DOMShared.query("#author-scene-beats"), + authorSaveScene: DOMShared.query("#author-save-scene"), + authorLongformStatus: DOMShared.query("#author-longform-status"), + authorSceneSummary: DOMShared.query("#author-scene-summary"), + authorLongformSummary: DOMShared.query("#author-longform-summary"), + authorSeriesTitle: DOMShared.query("#author-series-title"), + authorSeriesTheme: DOMShared.query("#author-series-theme"), + authorSeriesTotalVolumes: DOMShared.query("#author-series-total-volumes"), + authorSeriesTotalChapters: DOMShared.query("#author-series-total-chapters"), + authorSeriesTargetWords: DOMShared.query("#author-series-target-words"), + authorStorylineContract: DOMShared.query("#author-storyline-contract"), + authorCharacterMemoryProfiles: DOMShared.query("#author-character-memory-profiles"), + authorSteeringGuardrails: DOMShared.query("#author-steering-guardrails"), + authorVolumeSelect: DOMShared.query("#author-volume-select"), + authorVolumeTitle: DOMShared.query("#author-volume-title"), + authorVolumeGoal: DOMShared.query("#author-volume-goal"), + authorVolumeTargetChapters: DOMShared.query("#author-volume-target-chapters"), + authorVolumeClimax: DOMShared.query("#author-volume-climax"), + authorVolumeEndState: DOMShared.query("#author-volume-end-state"), + authorArcSelect: DOMShared.query("#author-arc-select"), + authorArcTitle: DOMShared.query("#author-arc-title"), + authorArcGoal: DOMShared.query("#author-arc-goal"), + authorArcConflict: DOMShared.query("#author-arc-conflict"), + authorArcTargetChapters: DOMShared.query("#author-arc-target-chapters"), + authorArcRevealBudget: DOMShared.query("#author-arc-reveal-budget"), + authorArcPayoffTargets: DOMShared.query("#author-arc-payoff-targets"), + authorArcCompletionConditions: DOMShared.query("#author-arc-completion-conditions"), + authorArcBoard: DOMShared.query("#author-arc-board"), + authorTaskSelect: DOMShared.query("#author-task-select"), + authorTaskBoard: DOMShared.query("#author-task-board"), + authorTaskId: DOMShared.query("#author-task-id"), + authorTaskDuty: DOMShared.query("#author-task-duty"), + authorTaskObjective: DOMShared.query("#author-task-objective"), + authorTaskTargetWords: DOMShared.query("#author-task-target-words"), + authorTaskRevealBudget: DOMShared.query("#author-task-reveal-budget"), + authorTaskPromiseActions: DOMShared.query("#author-task-promise-actions"), + authorTaskPromiseTargets: DOMShared.query("#author-task-promise-targets"), + authorTaskSplitTargets: DOMShared.query("#author-task-split-targets"), + authorTaskMergeObserved: DOMShared.query("#author-task-merge-observed"), + authorTaskAllowTerminal: DOMShared.query("#author-task-allow-terminal"), + authorArcTaskPreview: DOMShared.query("#author-arc-task-preview"), + authorBootstrapLongform: DOMShared.query("#author-bootstrap-longform"), + authorSaveLongform: DOMShared.query("#author-save-longform"), + authorTaskBulkState: DOMShared.query("#author-task-bulk-state"), + authorTaskBulkIssues: DOMShared.query("#author-task-bulk-issues"), + authorTaskBulkNotes: DOMShared.query("#author-task-bulk-notes"), + authorTaskBulkApply: DOMShared.query("#author-task-bulk-apply"), + authorTaskApplyRewrite: DOMShared.query("#author-task-apply-rewrite"), + authorRewritePatchPreview: DOMShared.query("#author-rewrite-patch-preview"), + authorExportRewritePatch: DOMShared.query("#author-export-rewrite-patch"), + authorVoiceEditor: DOMShared.query("#author-voice-editor"), + authorActionEditor: DOMShared.query("#author-action-editor"), + authorSensoryEditor: DOMShared.query("#author-sensory-editor"), + authorSceneEditor: DOMShared.query("#author-scene-editor"), + authorStyleSummary: DOMShared.query("#author-style-summary"), + authorStyleLexicon: DOMShared.query("#author-style-lexicon"), + authorThemeLabels: DOMShared.query("#author-theme-labels"), + authorHookTemplates: DOMShared.query("#author-hook-templates"), + authorPacingRequireTurnTaking: DOMShared.query("#author-pacing-require-turn-taking"), + authorPacingRequireCounterReaction: DOMShared.query("#author-pacing-require-counter-reaction"), + authorPacingMinTurns: DOMShared.query("#author-pacing-min-turns"), + authorPacingMaxTurns: DOMShared.query("#author-pacing-max-turns"), + authorPacingMinimumExchanges: DOMShared.query("#author-pacing-minimum-exchanges"), + authorPacingTurnPattern: DOMShared.query("#author-pacing-turn-pattern"), + authorSceneHooks: DOMShared.query("#author-scene-hooks"), + authorSaveCapabilities: DOMShared.query("#author-save-capabilities"), + authorSaveStyleControls: DOMShared.query("#author-save-style-controls"), + authorCommentAnchorType: DOMShared.query("#author-comment-anchor-type"), + authorCommentAnchorKey: DOMShared.query("#author-comment-anchor-key"), + authorCommentSeverity: DOMShared.query("#author-comment-severity"), + authorCommentAssignee: DOMShared.query("#author-comment-assignee"), + authorInboxReviewerId: DOMShared.query("#author-inbox-reviewer-id"), + authorInboxStatusFilter: DOMShared.query("#author-inbox-status-filter"), + authorInboxWorldVersionFilter: DOMShared.query("#author-inbox-world-version-filter"), + authorInboxNotificationTypeFilter: DOMShared.query("#author-inbox-notification-type-filter"), + authorInboxBlockingOnly: DOMShared.query("#author-inbox-blocking-only"), + authorInboxSearch: DOMShared.query("#author-inbox-search"), + authorRefreshReviewerInbox: DOMShared.query("#author-refresh-reviewer-inbox"), + authorSearchReviewerInbox: DOMShared.query("#author-search-reviewer-inbox"), + authorLoadMoreReviewerInbox: DOMShared.query("#author-load-more-reviewer-inbox"), + authorBulkReadVisible: DOMShared.query("#author-bulk-read-visible"), + authorBulkArchiveVisible: DOMShared.query("#author-bulk-archive-visible"), + authorDraftWatcherId: DOMShared.query("#author-draft-watcher-id"), + authorAddDraftWatcher: DOMShared.query("#author-add-draft-watcher"), + authorRemoveDraftWatcher: DOMShared.query("#author-remove-draft-watcher"), + authorNotificationPrefType: DOMShared.query("#author-notification-pref-type"), + authorNotificationPrefInApp: DOMShared.query("#author-notification-pref-in-app"), + authorNotificationPrefAsync: DOMShared.query("#author-notification-pref-async"), + authorNotificationPrefSink: DOMShared.query("#author-notification-pref-sink"), + authorNotificationPrefTarget: DOMShared.query("#author-notification-pref-target"), + authorRefreshNotificationPreferences: DOMShared.query("#author-refresh-notification-preferences"), + authorSaveNotificationPreference: DOMShared.query("#author-save-notification-preference"), + authorNotificationPreferences: DOMShared.query("#author-notification-preferences"), + authorCommentBody: DOMShared.query("#author-comment-body"), + authorApprovalReviewer: DOMShared.query("#author-approval-reviewer"), + authorApprovalReason: DOMShared.query("#author-approval-reason"), + authorCreateCommentThread: DOMShared.query("#author-create-comment-thread"), + authorRequestApproval: DOMShared.query("#author-request-approval"), + authorApproveDraft: DOMShared.query("#author-approve-draft"), + authorRequestChanges: DOMShared.query("#author-request-changes"), + authorReviewerGateNote: DOMShared.query("#author-reviewer-gate-note"), +}))(); diff --git a/src/narrativeos/web/author_workspace.js b/src/narrativeos/web/author_workspace.js new file mode 100644 index 0000000..af8ef10 --- /dev/null +++ b/src/narrativeos/web/author_workspace.js @@ -0,0 +1,7987 @@ +// Author workspace runtime extracted from app.js to keep Author flows isolated from shell and Reader. + +var AuthorWorkspaceRuntime = (() => { + const dom = AuthorDOM; + const { + api, + parseErrorDetail, + describeAuthError, + setBusy, + clearNode, + createListCard, + reportUiMessage, + formatPercent, + formatTimestamp, + downloadJsonFile, + parseMaybeJson + } = UIShared; + const { + getActiveDraftWorldpack, + getActiveRevisionHistory, + getLatestDiffSummary, + getDiffDrilldown, + getSimulationDrilldown, + getLongformDrilldown, + getPromiseLedgerWorkbench, + getPromiseStateWorkbench, + getSeriesVolumeArcPromiseMapping, + getChapterTaskSimulationLinking, + getContinuityDiffWorkbench, + getContinuityOverrideWorkbench, + getSimulationDiffCheckpoint, + selectedAuthorChapterMarker, + selectedAuthorCompareMarker, + alertAuthorGating + } = AuthorAccessors; + const { + tierLabel, + accessReasonLabel, + gatingStatusLabel, + gatingHint + } = ReaderAccessors; + const { refreshOpsSurface } = OpsRefreshRuntime; + +function authorStageLabel(stage) { + return { + brief: "准备起稿", + draft_created: "草稿已创建", + validated: "校验通过", + simulated: "诊断完成", + revised_after_simulation: "修改后待重跑", + ready_to_submit: "准备送审", + submitted: "已提交审核", + }[stage] || stage || "-"; +} + +function authorRecommendedActionLabel(actionId) { + return { + create_from_brief: "先整理灵感,再生成今天要打磨的草稿", + copy_current_world: "先复制当前世界,建立可编辑草稿", + bootstrap_quick_brief_enrich: "先补齐 100 章所需的长线骨架", + bootstrap_structured_longform: "先进入结构化长篇蓝图,再谈更长长度", + validate_draft: "先跑一次校验,找出当前阻塞", + simulate_draft: "先跑一次诊断,看看重点问题章节和问题队列", + submit_draft: "现在可以送审,交给审阅人", + focus_longform: "先看长篇规划与 readiness 阻塞", + focus_validation: "先看校验结果,确认当前阻塞", + focus_simulation: "先看模拟结果,判断要改哪几章", + focus_diff: "先看最近一次修改,确认这轮改了什么", + focus_revision: "先看版本对照,确认修改方向", + focus_version_history: "先看版本轨迹,确认这轮是否需要回退", + focus_draft_detail: "先看当前草稿摘要,确认工作对象", + }[actionId] || "先处理当前最重要的一步"; +} + +function authorRecommendedWorkspaceLabel(actionId) { + return { + create_from_brief: "起稿", + copy_current_world: "总览", + bootstrap_quick_brief_enrich: "创作台", + bootstrap_structured_longform: "创作台", + validate_draft: "总览", + simulate_draft: "总览", + submit_draft: "总览", + focus_longform: "创作台", + focus_validation: "问题诊断", + focus_simulation: "问题诊断", + focus_diff: "送审", + focus_revision: "送审", + focus_version_history: "送审", + focus_draft_detail: "总览", + }[actionId] || "总览"; +} + +function authorStageTone(status) { + if (["done", "completed", "ready", "submitted", "approved"].includes(String(status || "").toLowerCase())) return "is-complete"; + if (["active", "current", "in_progress", "in-review", "in_review"].includes(String(status || "").toLowerCase())) return "is-active"; + if (["blocked", "error", "failed"].includes(String(status || "").toLowerCase())) return "is-blocked"; + return "is-pending"; +} + +const AUTHOR_DRAFT_SECTION_CONFIG = [ + { key: "assets", label: "人物设定", description: "角色卡和场景蓝图。" }, + { key: "longform", label: "长篇规划", description: "系列、分卷、弧线与承诺映射。" }, + { key: "repair", label: "修稿桥", description: "承诺、章节任务和连续性修稿桥。" }, + { key: "style", label: "风格控制", description: "风格、节奏、钩子与高级配置。" }, +]; + +function authorNotice(message, kind = "warning") { + reportUiMessage(String(message), kind); +} + +function currentAuthorDraftSection() { + return AUTHOR_DRAFT_SECTION_CONFIG.some((item) => item.key === authorState.authorDraftSection) + ? authorState.authorDraftSection + : "assets"; +} + +function setAuthorDraftSection(sectionKey, options = {}) { + const nextSection = AUTHOR_DRAFT_SECTION_CONFIG.some((item) => item.key === sectionKey) ? sectionKey : "assets"; + authorState.authorDraftSection = nextSection; + syncAuthorDraftSectionPanels(); + if (!options.silent) { + renderAuthorDraftSectionNav(); + } +} + +function syncAuthorDraftSectionPanels() { + const activeSection = currentAuthorDraftSection(); + document.querySelectorAll("#author-shell [data-author-draft-section]").forEach((panel) => { + const hidden = panel.dataset.authorDraftSection !== activeSection; + panel.classList.toggle("is-hidden", hidden); + }); +} + +function isAuthorSessionExpiringSoon(expiresAt) { + const timestamp = Date.parse(String(expiresAt || "")); + if (!Number.isFinite(timestamp)) return false; + return timestamp > Date.now() && timestamp - Date.now() < 24 * 60 * 60 * 1000; +} + +function createAuthorSummaryCard({ title, score, body, warning = "", actionLabel = "", onAction = null, primary = false }) { + const card = createListCard({ title, score, body }); + card.classList.add("author-summary-card"); + if (warning) { + card.classList.add("is-warning"); + const warningNode = document.createElement("p"); + warningNode.className = "author-summary-warning"; + warningNode.textContent = warning; + card.appendChild(warningNode); + } + if (actionLabel && typeof onAction === "function") { + const actions = document.createElement("div"); + actions.className = "composer-actions author-card-actions"; + const button = document.createElement("button"); + button.className = primary ? "primary-action" : "ghost-action"; + button.textContent = actionLabel; + button.addEventListener("click", onAction); + actions.appendChild(button); + card.appendChild(actions); + } + return card; +} + +function createAuthorWorkCard({ title, score, body, warning = "", active = false, actions = [] }) { + const card = createListCard({ title, score, body }); + card.classList.add("author-work-card"); + if (active) { + card.classList.add("is-active"); + } + if (warning) { + card.classList.add("is-warning"); + const warningNode = document.createElement("p"); + warningNode.className = "author-summary-warning"; + warningNode.textContent = warning; + card.appendChild(warningNode); + } + if (actions.length) { + const actionRow = document.createElement("div"); + actionRow.className = "composer-actions author-card-actions"; + actions.forEach((item) => { + const button = document.createElement("button"); + button.className = item.primary ? "primary-action" : "ghost-action"; + button.textContent = item.label; + button.addEventListener("click", item.onClick); + actionRow.appendChild(button); + }); + card.appendChild(actionRow); + } + return card; +} + +function normalizeAuthorChoiceText(choice) { + if (typeof choice === "string") { + return choice.trim(); + } + if (!choice || typeof choice !== "object") { + return ""; + } + return String(choice.label || choice.text || choice.title || "").trim(); +} + +function focusAuthorPanel(panelKey) { + const mapping = { + workflow: { node: dom.authorWorkflow, workspace: "overview" }, + draft_detail: { node: dom.authorDraftDetail, workspace: "overview" }, + auth_settings: { node: dom.authorAuthStatus, workspace: "settings" }, + notification_settings: { node: dom.authorNotificationPreferences, workspace: "settings" }, + steering: { node: dom.authorSteeringComposer, workspace: "simulate" }, + validation: { node: dom.authorValidationReport, workspace: "simulate" }, + simulation: { node: dom.authorSimulationReport, workspace: "simulate" }, + creative_cockpit: { node: dom.authorCreativeCockpit, workspace: "simulate" }, + character_editor: { node: dom.authorCharacterSelect, workspace: "draft", section: "assets" }, + scene_editor: { node: dom.authorSceneSelect, workspace: "draft", section: "assets" }, + task_editor: { node: dom.authorTaskSelect, workspace: "draft", section: "longform" }, + longform: { node: dom.authorLongformStatus, workspace: "draft", section: "longform" }, + task_linking: { node: dom.authorTaskSimulationLinking, workspace: "draft", section: "repair" }, + continuity: { node: dom.authorContinuityDiff, workspace: "draft", section: "repair" }, + diff: { node: dom.authorAssetDiff, workspace: "review" }, + compare: { node: dom.authorCompare, workspace: "review" }, + collaboration: { node: dom.authorCollaboration, workspace: "settings" }, + version_history: { node: dom.authorVersionHistory, workspace: "review" }, + brief: { node: dom.authorCorePremise, workspace: "brief" }, + }; + const target = mapping[panelKey]; + if (target?.workspace) { + WorkspaceLayoutRuntime.setAuthorWorkspace(target.workspace, { silent: true }); + if (target.section) { + setAuthorDraftSection(target.section, { silent: true }); + } + ShellStatusRuntime.syncProductMode(); + } + const node = target?.node?.closest(".panel") || target?.node; + node?.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +function buildAuthorAdminViewUrl(workspace = "review") { + if (typeof window === "undefined") return ""; + const params = new URLSearchParams(); + params.set("product", "ops"); + params.set("workspace", workspace); + params.set("admin_view", "1"); + const accountId = + activeAuthorActorId() || + dom.authorAccountId?.value.trim() || + authorState.activeDraftDetail?.worldpack?.manifest?.author_id || + ""; + const worldId = authorState.activeDraftDetail?.world_id || authorState.activeDraftDetail?.worldpack?.world_id || ""; + const worldVersionId = authorState.activeDraftVersionId || ""; + if (accountId) params.set("account_id", accountId); + if (worldId) params.set("world_id", worldId); + if (worldVersionId) params.set("world_version_id", worldVersionId); + return `${window.location.pathname}?${params.toString()}`; +} + +async function openAuthorAdminView(workspace = "review") { + try { + const bridgePayload = { + workspace, + account_id: + activeAuthorActorId() || + dom.authorAccountId?.value.trim() || + authorState.activeDraftDetail?.worldpack?.manifest?.author_id || + null, + world_id: authorState.activeDraftDetail?.world_id || authorState.activeDraftDetail?.worldpack?.world_id || null, + world_version_id: authorState.activeDraftVersionId || null, + }; + const hasPrivilegedSession = ["reviewer", "ops", "admin"].includes( + String(authorState.authorAuthSession?.identity?.actor_role || "").trim() + ); + let payload; + if (hasPrivilegedSession && authorState.authorAuthSession?.accessToken) { + payload = await api("/v1/auth/admin-view-bridge", { + method: "POST", + body: JSON.stringify(bridgePayload), + }); + } else { + const reviewerActorId = window.prompt("输入 reviewer / ops 账号 ID"); + if (!reviewerActorId) { + authorNotice("已取消打开管理员视图。", "info"); + return; + } + const reviewerPassword = window.prompt("输入 reviewer / ops 密码"); + if (!reviewerPassword) { + authorNotice("已取消打开管理员视图。", "info"); + return; + } + payload = await api("/v1/auth/admin-view-session-bridge", { + method: "POST", + body: JSON.stringify({ + ...bridgePayload, + actor_id: reviewerActorId, + password: reviewerPassword, + }), + }); + } + const url = payload.url || buildAuthorAdminViewUrl(workspace); + if (!url) return; + const opened = window.open(url, "_blank", "noopener"); + if (!opened) { + window.location.href = url; + } + } catch (error) { + const detail = parseErrorDetail(error); + if (detail?.code === "admin_view_bridge_forbidden") { + authorNotice("当前登录身份没有管理员视图权限,需要 reviewer / ops / admin 账号。", "warning"); + return; + } + authorNotice(formatAuthorApiErrorMessage(error, "打开管理员视图失败,请稍后再试。"), "error"); + } +} + +function prefillAuthorCommentAnchor(anchorType, anchorKey) { + if (dom.authorCommentAnchorType) { + dom.authorCommentAnchorType.value = anchorType || "draft"; + } + if (dom.authorCommentAnchorKey) { + dom.authorCommentAnchorKey.value = anchorKey || ""; + } + focusAuthorPanel("collaboration"); +} + +function activeAuthorAccountId() { + return dom.authorAccountId?.value.trim() || authorState.authorAuthSession?.identity?.account_id || ""; +} + +function preferredAuthorDraftVersionId(drafts = []) { + const preferredAccountId = String( + authorState.authorAuthSession?.identity?.account_id || + dom.authorAccountId?.value.trim() || + "" + ).trim(); + if (!Array.isArray(drafts) || !drafts.length) return null; + const ownedDraft = preferredAccountId + ? drafts.find((item) => String(item.author_id || "").trim() === preferredAccountId) + : null; + return (ownedDraft || drafts[0])?.world_version_id || null; +} + +function currentDraftAuthorAccountId() { + return ( + authorState.activeDraftDetail?.worldpack?.manifest?.author_id || + authorState.activeDraftDetail?.author_id || + dom.authorAccountId?.value.trim() || + "" + ).trim(); +} + +function expectedDraftAuthorAccountId() { + if (typeof window === "undefined") return currentDraftAuthorAccountId(); + const params = new URLSearchParams(window.location.search); + return String(currentDraftAuthorAccountId() || params.get("account_id") || "").trim(); +} + +function primeAuthorIdentityInputs(preferredAccountId = "") { + const normalized = String(preferredAccountId || "").trim(); + if (normalized && dom.authorAccountId) { + dom.authorAccountId.value = normalized; + } + if (normalized && dom.authorAuthActorId && !dom.authorAuthActorId.value.trim()) { + dom.authorAuthActorId.value = normalized; + } +} + +function handleAuthorWorkAccessError(error, actionLabel) { + const detail = parseErrorDetail(error); + const resolvedDetail = detail?.detail && typeof detail.detail === "object" ? detail.detail : detail; + const expectedAccountId = expectedDraftAuthorAccountId(); + const currentAccountId = activeAuthorAccountId(); + if (resolvedDetail?.code === "author_entitlement_required") { + alertAuthorGating(resolvedDetail, actionLabel); + return true; + } + if (resolvedDetail?.code === "author_work_identity_required") { + primeAuthorIdentityInputs(expectedAccountId || currentAccountId); + focusAuthorPanel("auth_settings"); + authorNotice( + `要${actionLabel},请先在“账户协作”里登录作者账号 ${expectedAccountId || currentAccountId || "当前草稿对应账号"}。URL 里的 account_id 只用于定位,不代表已登录。`, + "warning" + ); + return true; + } + if (resolvedDetail?.code === "author_work_forbidden") { + primeAuthorIdentityInputs(expectedAccountId); + focusAuthorPanel("auth_settings"); + authorNotice( + `当前登录账号 ${currentAccountId || "-"} 和这份 Draft 的作者账号 ${expectedAccountId || "-"} 不一致。请切换到正确账号后再${actionLabel}。`, + "warning" + ); + return true; + } + return false; +} + +function formatAuthorQualityGateSummary(qualityGate) { + const gate = qualityGate || {}; + const issues = Array.isArray(gate.issues) ? gate.issues : []; + const issueCodes = Array.from(new Set(issues.map((item) => String(item?.issue_code || "").trim()).filter(Boolean))); + const owningModules = Array.from(new Set((gate.owning_modules || []).map((item) => String(item || "").trim()).filter(Boolean))); + const failedChecks = Array.from(new Set((gate.failed_checks || []).map((item) => String(item || "").trim()).filter(Boolean))); + return [ + `gate ${gate.code || "chapter_quality_guard_failed"}`, + `字数 ${Number(gate.actual_text_units || 0)} / ${Number(gate.required_text_units || 0) || "-"}`, + `decision ${gate.decision || "-"} -> ${gate.enforced_decision || gate.decision || "-"}`, + `issues ${issueCodes.join(" / ") || "-"}`, + `模块 ${owningModules.join(" / ") || "-"}`, + `失败项 ${failedChecks.join(" / ") || "-"}`, + ].join("\n"); +} + +function handleAuthorWorkQualityGateError(error, actionLabel) { + const detail = parseErrorDetail(error); + const resolvedDetail = detail?.detail && typeof detail.detail === "object" ? detail.detail : detail; + const qualityGate = resolvedDetail?.quality_gate; + if (resolvedDetail?.code !== "chapter_quality_guard_failed" || !qualityGate) { + return false; + } + authorState.authorWorkQualityGateFailure = { + action_label: actionLabel, + quality_gate: qualityGate, + }; + if (actionLabel === "保存章节") { + authorState.activeWorkSaveState = "blocked"; + refreshAuthorWorkEditorChrome(); + } + renderAuthorReports(); + authorNotice( + `${actionLabel}未通过章节硬约束:${Number(qualityGate.actual_text_units || 0)}/${Number(qualityGate.required_text_units || 0) || "-"} 字,${(qualityGate.issues || []).map((item) => item.issue_code).filter(Boolean).join(" / ") || qualityGate.enforced_decision || "rewrite"}`, + "error" + ); + return true; +} + +function normalizedAuthorErrorDetail(error) { + const detail = parseErrorDetail(error); + return detail?.detail && typeof detail.detail === "object" ? detail.detail : detail; +} + +function formatAuthorApiErrorMessage(error, fallbackMessage) { + const detail = normalizedAuthorErrorDetail(error) || {}; + const code = String(detail.code || "").trim(); + const reason = String(detail.reason || "").trim(); + const status = Number(error?.status || 0); + if (code === "auth_token_invalid" || code === "auth_token_missing" || status === 401) { + return "作者登录已失效,请先在“账户协作”里重新登录。"; + } + if (code === "author_work_identity_required") { + return "当前还没有作者登录态。请先在“账户协作”里登录正确作者账号。"; + } + if (code === "author_work_forbidden") { + return "当前账号没有这份 Draft 的访问权限,请切换到正确作者账号后再试。"; + } + if (code === "author_entitlement_required") { + const requiredTier = detail.required_display_name || tierLabel(detail.required_tier) || "所需权益"; + return `当前不能继续这一步:${accessReasonLabel(detail.reason)}。需要 ${requiredTier}。`; + } + if (status === 404) { + return "当前 Draft 或关联对象不存在,请刷新后重试。"; + } + if (code === "chapter_quality_guard_failed") { + return "章节硬约束未通过,请先修正文长、问题码和连续性后再试。"; + } + if (code === "author_collaboration_forbidden" || status === 403) { + return "当前账号暂时没有执行这个创作动作的权限。"; + } + const detailMessage = [detail.message, detail.detail, reason] + .find((value) => typeof value === "string" && value.trim() && !value.includes("{") && value !== code); + if (detailMessage) { + return `${fallbackMessage}:${detailMessage.trim()}`; + } + const rawMessage = String(error?.message || "").trim(); + if (rawMessage && rawMessage !== "[object Object]" && !rawMessage.startsWith("{")) { + return `${fallbackMessage}:${rawMessage}`; + } + return fallbackMessage; +} + +function shouldPromptAuthorLoginForDeepLink() { + if (typeof window === "undefined" || shellState.activeProduct !== "author" || authorState.authorAuthSession?.accessToken) { + return false; + } + const params = new URLSearchParams(window.location.search); + return Boolean(params.get("draft_id") && params.get("account_id")); +} + +function shouldPromptAuthorAccountSwitchForDeepLink(currentAccountId = "") { + if (typeof window === "undefined" || shellState.activeProduct !== "author" || !authorState.authorAuthSession?.accessToken) { + return false; + } + const params = new URLSearchParams(window.location.search); + const draftId = params.get("draft_id") || ""; + const expectedAccountId = expectedDraftAuthorAccountId(); + const normalizedCurrent = String(currentAccountId || authorState.authorAuthSession?.identity?.account_id || "").trim(); + return Boolean(draftId && expectedAccountId && normalizedCurrent && expectedAccountId !== normalizedCurrent); +} + +function authorDeepLinkResumeUrl(preferredAccountId = "") { + if (typeof window === "undefined" || shellState.activeProduct !== "author") return ""; + const params = new URLSearchParams(window.location.search); + const draftId = params.get("draft_id") || authorState.activeDraftVersionId || ""; + const deepLinkAccountId = expectedDraftAuthorAccountId(); + const sessionAccountId = String(authorState.authorAuthSession?.identity?.account_id || "").trim(); + const resolvedAccountId = String(preferredAccountId || sessionAccountId || "").trim(); + const hasAuthorSession = Boolean(authorState.authorAuthSession?.accessToken && sessionAccountId); + if (!hasAuthorSession) { + return ""; + } + if (!draftId || !deepLinkAccountId || !resolvedAccountId || deepLinkAccountId !== resolvedAccountId) { + return ""; + } + const currentUrl = new URL(window.location.href); + const isDeepLinkEntrySurface = + currentUrl.pathname === "/app/user" || + (currentUrl.pathname === "/app" && currentUrl.searchParams.get("workspace") === "settings"); + if (!isDeepLinkEntrySurface) { + return ""; + } + const alreadyResolved = + currentUrl.pathname === "/app" && + currentUrl.searchParams.get("product") === "author" && + currentUrl.searchParams.get("workspace") === "draft" && + currentUrl.searchParams.get("draft_id") === draftId && + currentUrl.searchParams.get("account_id") === resolvedAccountId; + if (alreadyResolved) return ""; + const next = new URL(window.location.origin + "/app"); + next.searchParams.set("product", "author"); + next.searchParams.set("workspace", "draft"); + next.searchParams.set("draft_id", draftId); + next.searchParams.set("account_id", resolvedAccountId); + if (shellState.debug || currentUrl.searchParams.get("debug") === "1") { + next.searchParams.set("debug", "1"); + } + return next.toString(); +} + +function resumeAuthorDeepLinkIfPossible(preferredAccountId = "") { + const resumeUrl = authorDeepLinkResumeUrl(preferredAccountId); + if (!resumeUrl) return false; + window.location.replace(resumeUrl); + return true; +} + +function ensureAuthorDeepLinkLoginPrompt() { + if (!shouldPromptAuthorLoginForDeepLink()) return; + const accountId = expectedDraftAuthorAccountId(); + primeAuthorIdentityInputs(accountId); + if (shellState.authorWorkspace !== "settings") { + WorkspaceLayoutRuntime.setAuthorWorkspace("settings", { silent: true }); + ShellStatusRuntime.syncProductMode(); + authorNotice(`登录这个作者后继续创作:${accountId || "当前草稿对应账号"}。`, "info"); + } +} + +function clearMismatchedAuthorDeepLinkContext(currentAccountId = "") { + const normalizedCurrent = String(currentAccountId || authorState.authorAuthSession?.identity?.account_id || "").trim(); + authorState.activeDraftVersionId = null; + authorState.activeDraftDetail = null; + authorState.authorValidationReport = null; + authorState.authorSimulationReport = null; + authorState.authorPreviousSimulationReport = null; + authorState.authorWorks = []; + authorState.activeWorkId = null; + authorState.activeWorkDetail = null; + authorState.activeWorkChapterIndex = null; + authorState.activeWorkChapterDetail = null; + authorState.activeWorkChapterDraft = null; + authorState.activeWorkChapterDirty = false; + authorState.activeWorkSaveState = "idle"; + authorState.authorWorkDiagnostics = null; + authorState.authorWorkQualityGateFailure = null; + authorState.pendingAuthorBranchSeed = null; + if (dom.authorAccountId && normalizedCurrent) { + dom.authorAccountId.value = normalizedCurrent; + } + if (dom.authorAuthActorId && !dom.authorAuthActorId.value.trim() && normalizedCurrent) { + dom.authorAuthActorId.value = normalizedCurrent; + } + if (shellState.authorWorkspace !== "settings") { + WorkspaceLayoutRuntime.setAuthorWorkspace("settings", { silent: true }); + ShellStatusRuntime.syncProductMode(); + } + if (typeof RouteSyncRuntime !== "undefined" && typeof RouteSyncRuntime.syncShellRoute === "function") { + RouteSyncRuntime.syncShellRoute(); + } +} + +function ensureAuthorDeepLinkAccountSwitchPrompt(currentAccountId = "") { + if (!shouldPromptAuthorAccountSwitchForDeepLink(currentAccountId)) return; + const expectedAccountId = expectedDraftAuthorAccountId(); + clearMismatchedAuthorDeepLinkContext(currentAccountId); + primeAuthorIdentityInputs(currentAccountId || expectedAccountId); + authorNotice( + `当前登录账号 ${currentAccountId || "-"} 和这条创作深链要求的作者账号 ${expectedAccountId || "-"} 不一致。错误深链参数已清除,当前登录会保留;如需继续该草稿,请切换到正确账号后重新打开。`, + "warning" + ); +} + +function normalizeAuthorDraftRouteAccount() { + if (typeof window === "undefined" || shellState.activeProduct !== "author") return false; + const expectedAccountId = currentDraftAuthorAccountId(); + if (!expectedAccountId) return false; + const params = new URLSearchParams(window.location.search); + const urlAccountId = String(params.get("account_id") || "").trim(); + let changed = false; + if (dom.authorAccountId && dom.authorAccountId.value.trim() !== expectedAccountId) { + dom.authorAccountId.value = expectedAccountId; + changed = true; + } + if (dom.authorAuthActorId && !dom.authorAuthActorId.value.trim()) { + dom.authorAuthActorId.value = expectedAccountId; + } + if (urlAccountId !== expectedAccountId) { + changed = true; + } + if (changed && typeof RouteSyncRuntime !== "undefined" && typeof RouteSyncRuntime.syncShellRoute === "function") { + RouteSyncRuntime.syncShellRoute(); + } + return changed; +} + +function authorWorkStatusLabel(status) { + return { + draft: "创作中", + review_ready: "可送审", + submitted: "已送审", + approved: "已批准", + needs_changes: "待修改", + }[String(status || "").toLowerCase()] || status || "-"; +} + +function authorWorkStatusTone(status) { + const normalized = String(status || "").toLowerCase(); + if (["review_ready", "approved"].includes(normalized)) return "is-complete"; + if (["submitted"].includes(normalized)) return "is-active"; + if (["needs_changes"].includes(normalized)) return "is-blocked"; + return "is-pending"; +} + +function authorWorkNextAction(work) { + if (!work) return "初始化作品稿"; + if (!Number(work.chapter_count || 0)) return "生成第一章"; + if (work.status === "review_ready") return "送审作品稿"; + if (work.status === "submitted") return "等待审阅结果"; + if (work.status === "needs_changes") return "修订后重跑诊断"; + return "生成下一章"; +} + +function buildAuthorWorkDraft(chapter) { + if (!chapter) return null; + return { + chapter_index: Number(chapter.chapter_index || 0) || null, + chapter_title: chapter.chapter_title || "", + summary: chapter.summary || "", + body: chapter.body || "", + }; +} + +function activeAuthorWorkDraft() { + return authorState.activeWorkChapterDraft || buildAuthorWorkDraft(authorState.activeWorkChapterDetail?.chapter || null); +} + +function isAuthorWorkDraftDirty() { + return Boolean(authorState.activeWorkChapterDirty); +} + +function chapterDraftMatchesDetail(draft, chapter) { + if (!draft || !chapter) return false; + return ( + String(draft.chapter_title || "") === String(chapter.chapter_title || "") && + String(draft.summary || "") === String(chapter.summary || "") && + String(draft.body || "") === String(chapter.body || "") + ); +} + +function syncAuthorWorkDraftFromDetail(chapterPayload, options = {}) { + const chapter = chapterPayload?.chapter || null; + if (!chapter) { + authorState.activeWorkChapterDraft = null; + authorState.activeWorkChapterDirty = false; + authorState.activeWorkSaveState = "idle"; + return; + } + const nextDraft = buildAuthorWorkDraft(chapter); + const sameChapter = Number(authorState.activeWorkChapterDraft?.chapter_index || 0) === Number(nextDraft?.chapter_index || 0); + if (options.preserveDirty && sameChapter && authorState.activeWorkChapterDirty) { + return; + } + authorState.activeWorkChapterDraft = nextDraft; + authorState.activeWorkChapterDirty = false; + authorState.activeWorkSaveState = "idle"; +} + +function discardAuthorWorkDraft() { + authorState.activeWorkChapterDirty = false; + authorState.activeWorkSaveState = "idle"; + authorState.activeWorkChapterDraft = buildAuthorWorkDraft(authorState.activeWorkChapterDetail?.chapter || null); +} + +function summarizeAuthorExecutionError(error) { + const detail = parseErrorDetail(error); + const resolved = detail?.detail && typeof detail.detail === "object" ? detail.detail : detail; + const message = + resolved?.message || + resolved?.detail || + resolved?.code || + (typeof error?.message === "string" ? error.message : "") || + "未知错误"; + return String(message || "未知错误").trim(); +} + +function clearAuthorBranchExecutionState(options = {}) { + authorState.authorBranchExecutionState = null; + if (options.silent) return; + renderAuthorSteeringComposer(); + refreshAuthorWorkEditorChrome(); +} + +function setAuthorBranchExecutionState(nextState) { + authorState.authorBranchExecutionState = { + ...(authorState.authorBranchExecutionState || {}), + ...nextState, + updated_at: new Date().toISOString(), + }; + renderAuthorSteeringComposer(); + refreshAuthorWorkEditorChrome(); +} + +function buildAuthorBranchExecutionPresentation() { + const state = authorState.authorBranchExecutionState || null; + if (!state) { + return { + title: "命运线执行状态", + score: "尚未执行", + body: "点击“带着引导重跑 Simulation”后,这里会明确显示创建分支成功 / 失败,以及当前分支已生成到第几章。", + tone: "neutral", + inlineText: "", + }; + } + const branchName = String(state.branchName || "平行宇宙").trim() || "平行宇宙"; + const forkAfterChapterIndex = Number(state.forkAfterChapterIndex || 0) || 0; + const nextChapterIndex = Number(state.nextChapterIndex || (forkAfterChapterIndex > 0 ? forkAfterChapterIndex + 1 : 0)) || 0; + const currentChapterCount = Number(state.currentChapterCount || 0) || 0; + const errorMessage = String(state.errorMessage || state.message || "").trim(); + const currentRevision = state.currentRevision ? ` · ${state.currentRevision}` : ""; + switch (state.stage) { + case "branch_pending": + return { + title: "命运线执行状态", + score: "创建分支中", + body: `正在从第 ${forkAfterChapterIndex || "-"} 章后创建新命运线。\n成功后会继续生成第 ${nextChapterIndex || "-"} 章,不会改写过去章节。`, + tone: "running", + inlineText: `正在从第 ${forkAfterChapterIndex || "-"} 章后创建新命运线。`, + }; + case "generate_pending": + return { + title: "命运线执行状态", + score: `正在生成第 ${nextChapterIndex || "-"} 章`, + body: `创建分支成功 · ${branchName}\n分叉点:第 ${forkAfterChapterIndex || "-"} 章后\n当前分支停在第 ${currentChapterCount || forkAfterChapterIndex || "-"} 章,正在按本次引导生成第 ${nextChapterIndex || "-"} 章。`, + tone: "running", + inlineText: `已创建 ${branchName},正在生成第 ${nextChapterIndex || "-"} 章。`, + }; + case "generate_succeeded": + return { + title: "命运线执行状态", + score: `已生成第 ${currentChapterCount || nextChapterIndex || "-"} 章`, + body: `创建分支成功 · ${branchName}\n分叉点:第 ${forkAfterChapterIndex || "-"} 章后\n当前分支已生成到第 ${currentChapterCount || nextChapterIndex || "-"} 章${currentRevision}\n这条命运线后续会沿当前引导继续推进。`, + tone: "success", + inlineText: `已创建 ${branchName},并生成第 ${currentChapterCount || nextChapterIndex || "-"} 章。`, + }; + case "generate_failed": + return { + title: "命运线执行状态", + score: "生成失败", + body: `创建分支成功 · ${branchName}\n分叉点:第 ${forkAfterChapterIndex || "-"} 章后\n当前分支仍停在第 ${currentChapterCount || forkAfterChapterIndex || "-"} 章,未能生成第 ${nextChapterIndex || "-"} 章。\n错误:${errorMessage || "未知错误"}`, + tone: "warning", + inlineText: `已创建 ${branchName},但第 ${nextChapterIndex || "-"} 章生成失败。`, + }; + case "branch_create_failed": + return { + title: "命运线执行状态", + score: "创建失败", + body: `创建新命运线失败,当前仍停留在原命运线。\n计划分叉点:第 ${forkAfterChapterIndex || "-"} 章后 · 目标章节:第 ${nextChapterIndex || "-"} 章\n错误:${errorMessage || "未知错误"}`, + tone: "danger", + inlineText: `创建新命运线失败:${errorMessage || "未知错误"}`, + }; + default: + return { + title: "命运线执行状态", + score: "尚未执行", + body: "点击“带着引导重跑 Simulation”后,这里会明确显示创建分支成功 / 失败,以及当前分支已生成到第几章。", + tone: "neutral", + inlineText: "", + }; + } +} + +function refreshAuthorWorkEditorChrome() { + const stateNode = document.querySelector("#author-work-editor-state"); + const saveButton = document.querySelector("#author-save-work-chapter"); + const dirtyPill = document.querySelector("#author-work-dirty-pill"); + const chapterHeadline = document.querySelector("#author-work-editor-current-chapter"); + const chapter = authorState.activeWorkChapterDetail?.chapter || null; + if (chapterHeadline) { + chapterHeadline.textContent = chapter ? `第 ${chapter.chapter_index} 章` : "未选择章节"; + } + const isDirty = Boolean(authorState.activeWorkChapterDirty); + const saveState = authorState.activeWorkSaveState || "idle"; + const branchExecution = buildAuthorBranchExecutionPresentation(); + const baseTone = + saveState === "blocked" ? "warning" : isDirty ? "warning" : saveState === "saved" ? "success" : "neutral"; + if (dirtyPill) { + dirtyPill.textContent = + saveState === "blocked" ? "未入库" : isDirty ? "未保存改动" : saveState === "saved" ? "已保存" : "已同步"; + dirtyPill.dataset.tone = + saveState === "blocked" ? "warning" : isDirty ? "warning" : saveState === "saved" ? "success" : "neutral"; + } + if (stateNode) { + const baseText = + saveState === "saving" + ? "正在保存当前章节…" + : saveState === "blocked" + ? "未保存,未通过章节硬约束。先补足字数、场景动作或节奏问题,再重试。" + : isDirty + ? "当前章节有未保存改动,切章前会提示确认。" + : saveState === "saved" + ? "当前章节已保存,可以继续生成、诊断或送审。" + : "当前章节与服务器已同步。"; + stateNode.textContent = branchExecution.inlineText ? `${baseText}\n${branchExecution.inlineText}` : baseText; + stateNode.dataset.tone = + saveState === "saving" || saveState === "blocked" || isDirty ? baseTone : branchExecution.tone || baseTone; + } + if (saveButton) { + saveButton.disabled = !chapter || saveState === "saving" || !isDirty; + saveButton.textContent = saveState === "saving" ? "保存中…" : "保存当前章节"; + } +} + +function updateAuthorWorkDraftField(field, value) { + const chapter = authorState.activeWorkChapterDetail?.chapter || null; + if (!chapter) return; + const currentDraft = activeAuthorWorkDraft() || buildAuthorWorkDraft(chapter); + const nextDraft = { + ...currentDraft, + [field]: value, + }; + authorState.activeWorkChapterDraft = nextDraft; + authorState.activeWorkChapterDirty = !chapterDraftMatchesDetail(nextDraft, chapter); + if (authorState.activeWorkChapterDirty) { + authorState.activeWorkSaveState = "idle"; + } + refreshAuthorWorkEditorChrome(); +} + +function confirmAuthorWorkDiscard(message) { + if (!isAuthorWorkDraftDirty()) return true; + return window.confirm(message || "当前章节还有未保存改动,确认放弃这些修改吗?"); +} + +function preferredAuthorWorkChapterIndex(chapters) { + if (!Array.isArray(chapters) || !chapters.length) return null; + if (chapters.some((item) => Number(item.chapter_index) === Number(authorState.activeWorkChapterIndex))) { + return Number(authorState.activeWorkChapterIndex); + } + return Number(chapters[chapters.length - 1].chapter_index); +} + +async function refreshAuthorWorks(accountId) { + authorState.authorWorkQualityGateFailure = null; + if (!authorState.activeDraftVersionId || !accountId) { + authorState.authorWorks = []; + authorState.activeWorkId = null; + authorState.activeWorkDetail = null; + authorState.activeWorkChapterIndex = null; + authorState.activeWorkChapterDetail = null; + authorState.activeWorkChapterDraft = null; + authorState.activeWorkChapterDirty = false; + authorState.activeWorkSaveState = "idle"; + authorState.authorBranchExecutionState = null; + authorState.authorWorkDiagnostics = null; + authorState.authorWorkQualityGateFailure = null; + return; + } + const payload = await api( + `/v1/author/works?account_id=${encodeURIComponent(accountId)}&world_version_id=${encodeURIComponent(authorState.activeDraftVersionId)}` + ); + authorState.authorWorks = payload.works || []; + if (!authorState.authorWorks.length) { + authorState.activeWorkId = null; + authorState.activeWorkDetail = null; + authorState.activeWorkChapterIndex = null; + authorState.activeWorkChapterDetail = null; + authorState.activeWorkChapterDraft = null; + authorState.activeWorkChapterDirty = false; + authorState.activeWorkSaveState = "idle"; + authorState.authorBranchExecutionState = null; + authorState.authorWorkDiagnostics = null; + authorState.authorWorkQualityGateFailure = null; + return; + } + if (!authorState.authorWorks.some((item) => item.work_id === authorState.activeWorkId)) { + authorState.activeWorkId = (authorState.authorWorks.find((item) => item.is_active_line) || authorState.authorWorks[0]).work_id; + } + authorState.activeWorkDetail = await api(`/v1/author/works/${encodeURIComponent(authorState.activeWorkId)}`); + authorState.authorWorkDiagnostics = authorState.activeWorkDetail?.diagnostics_summary || authorState.activeWorkDetail?.diagnostics_summary_json || null; + const chapters = authorState.activeWorkDetail?.chapters || []; + if (!chapters.length) { + authorState.activeWorkChapterIndex = null; + authorState.activeWorkChapterDetail = null; + authorState.activeWorkChapterDraft = null; + authorState.activeWorkChapterDirty = false; + authorState.activeWorkSaveState = "idle"; + return; + } + authorState.activeWorkChapterIndex = preferredAuthorWorkChapterIndex(chapters); + authorState.activeWorkChapterDetail = await api( + `/v1/author/works/${encodeURIComponent(authorState.activeWorkId)}/chapters/${encodeURIComponent(authorState.activeWorkChapterIndex)}` + ); + syncAuthorWorkDraftFromDetail(authorState.activeWorkChapterDetail, { preserveDirty: true }); +} + +async function switchActiveAuthorWork(workId, options = {}) { + const normalized = String(workId || "").trim(); + if (!normalized || normalized === authorState.activeWorkId) return; + if ( + !options.force && + !confirmAuthorWorkDiscard("当前章节还有未保存改动,切换到另一条命运线会放弃这些本地修改。确认继续吗?") + ) { + return; + } + const activated = await api(`/v1/author/works/${encodeURIComponent(normalized)}/activate-line`, { + method: "POST", + body: JSON.stringify({ + account_id: activeAuthorAccountId() || null, + }), + }); + authorState.activeWorkId = activated.work_id || normalized; + authorState.pendingAuthorBranchSeed = null; + clearAuthorBranchExecutionState({ silent: true }); + await refreshAuthorWorks(activeAuthorAccountId()); + renderAuthorReports(); +} + +async function createAuthorWorkFromDraft() { + if (!authorState.activeDraftVersionId) { + authorNotice("先选择一个 draft。"); + return; + } + if (!confirmAuthorWorkDiscard("当前章节还有未保存改动,初始化或刷新作品稿会覆盖当前编辑器内容。确认继续吗?")) { + return; + } + try { + const payload = await api("/v1/author/works", { + method: "POST", + body: JSON.stringify({ + world_version_id: authorState.activeDraftVersionId, + account_id: activeAuthorAccountId() || null, + }), + }); + authorState.activeWorkId = payload.work_id; + authorState.authorWorkQualityGateFailure = null; + authorState.activeWorkChapterDraft = null; + authorState.activeWorkChapterDirty = false; + authorState.activeWorkSaveState = "idle"; + clearAuthorBranchExecutionState({ silent: true }); + await refreshAuthorWorks(activeAuthorAccountId()); + if (typeof ReaderRuntime !== "undefined" && typeof ReaderRuntime.refreshAuthoredWorkLibrary === "function") { + ReaderRuntime.refreshAuthoredWorkLibrary().catch(() => {}); + } + renderAuthorReports(); + } catch (error) { + if (handleAuthorWorkAccessError(error, "初始化作品稿")) { + return; + } + throw error; + } +} + +async function generateAuthorWork(mode) { + if (!authorState.activeWorkId) { + authorNotice("先初始化作品稿。"); + return null; + } + if (!confirmAuthorWorkDiscard("当前章节还有未保存改动。生成新章节前需要先保存,或确认放弃本地修改。确认继续吗?")) { + return null; + } + try { + const payload = await api(`/v1/author/works/${encodeURIComponent(authorState.activeWorkId)}/chapters/generate`, { + method: "POST", + body: JSON.stringify({ + mode, + account_id: activeAuthorAccountId() || null, + }), + }); + authorState.activeWorkDetail = payload; + authorState.authorWorkDiagnostics = payload?.diagnostics_summary || payload?.diagnostics_summary_json || null; + authorState.authorWorkQualityGateFailure = null; + const chapters = payload?.chapters || []; + authorState.activeWorkChapterDraft = null; + authorState.activeWorkChapterDirty = false; + authorState.activeWorkSaveState = "idle"; + if (chapters.length) { + authorState.activeWorkChapterIndex = chapters[chapters.length - 1].chapter_index; + authorState.activeWorkChapterDetail = await api( + `/v1/author/works/${encodeURIComponent(authorState.activeWorkId)}/chapters/${encodeURIComponent(authorState.activeWorkChapterIndex)}` + ); + syncAuthorWorkDraftFromDetail(authorState.activeWorkChapterDetail); + } + await refreshAuthorWorks(activeAuthorAccountId()); + if (typeof ReaderRuntime !== "undefined" && typeof ReaderRuntime.refreshAuthoredWorkLibrary === "function") { + ReaderRuntime.refreshAuthoredWorkLibrary().catch(() => {}); + } + renderAuthorReports(); + return payload; + } catch (error) { + if (handleAuthorWorkQualityGateError(error, "生成章节")) { + return null; + } + if (handleAuthorWorkAccessError(error, "生成章节")) { + return null; + } + throw error; + } +} + +async function loadAuthorWorkChapter(chapterIndex, options = {}) { + if (!authorState.activeWorkId) return; + const normalizedIndex = Number(chapterIndex || 0); + if (!normalizedIndex) return; + if ( + !options.force && + Number(authorState.activeWorkChapterIndex || 0) !== normalizedIndex && + !confirmAuthorWorkDiscard("当前章节还有未保存改动,确认切换到另一章并放弃这些修改吗?") + ) { + return; + } + try { + authorState.activeWorkChapterIndex = normalizedIndex; + authorState.activeWorkChapterDetail = await api( + `/v1/author/works/${encodeURIComponent(authorState.activeWorkId)}/chapters/${encodeURIComponent(authorState.activeWorkChapterIndex)}` + ); + syncAuthorWorkDraftFromDetail(authorState.activeWorkChapterDetail); + renderAuthorReports(); + } catch (error) { + if (handleAuthorWorkAccessError(error, "加载章节")) { + return; + } + throw error; + } +} + +async function saveAuthorWorkChapter() { + if (!authorState.activeWorkId || !authorState.activeWorkChapterIndex) { + authorNotice("先选择一个章节。"); + return; + } + if (!isAuthorWorkDraftDirty()) { + authorNotice("当前章节没有未保存改动。", "warning"); + return; + } + const draft = activeAuthorWorkDraft(); + if (!draft) { + authorNotice("当前章节还没有可保存内容。", "warning"); + return; + } + authorState.activeWorkSaveState = "saving"; + refreshAuthorWorkEditorChrome(); + try { + const payload = await api( + `/v1/author/works/${encodeURIComponent(authorState.activeWorkId)}/chapters/${encodeURIComponent(authorState.activeWorkChapterIndex)}/edit`, + { + method: "POST", + body: JSON.stringify({ + chapter_title: draft.chapter_title || null, + body: draft.body || null, + summary: draft.summary || null, + account_id: activeAuthorAccountId() || null, + }), + } + ); + authorState.activeWorkDetail = payload.work; + authorState.activeWorkChapterDetail = { work: payload.work, chapter: payload.chapter }; + authorState.authorWorkDiagnostics = payload.work?.diagnostics_summary || payload.work?.diagnostics_summary_json || null; + authorState.authorWorkQualityGateFailure = null; + syncAuthorWorkDraftFromDetail(authorState.activeWorkChapterDetail); + authorState.activeWorkSaveState = "saved"; + renderAuthorReports(); + authorNotice("当前章节已保存。", "success"); + } catch (error) { + authorState.activeWorkSaveState = "idle"; + refreshAuthorWorkEditorChrome(); + if (handleAuthorWorkQualityGateError(error, "保存章节")) { + return; + } + if (handleAuthorWorkAccessError(error, "保存章节")) { + return; + } + throw error; + } +} + +async function runAuthorWorkDiagnostics() { + if (!authorState.activeWorkId) { + authorNotice("先初始化作品稿。"); + return; + } + if (isAuthorWorkDraftDirty()) { + authorNotice("先保存当前章节,再运行作品诊断。", "warning"); + return; + } + try { + const payload = await api(`/v1/author/works/${encodeURIComponent(authorState.activeWorkId)}/diagnostics/run`, { + method: "POST", + body: JSON.stringify({ + account_id: activeAuthorAccountId() || null, + }), + }); + authorState.activeWorkDetail = payload.work; + authorState.authorWorkDiagnostics = payload.work?.diagnostics_summary || payload.work?.diagnostics_summary_json || null; + await refreshAuthorWorks(activeAuthorAccountId()); + renderAuthorReports(); + } catch (error) { + if (handleAuthorWorkAccessError(error, "运行作品诊断")) { + return; + } + throw error; + } +} + +async function submitAuthorWorkForReview() { + if (!authorState.activeWorkId) { + authorNotice("先初始化作品稿。"); + return; + } + if (isAuthorWorkDraftDirty()) { + authorNotice("先保存当前章节,再送审作品稿。", "warning"); + return; + } + try { + const payload = await api(`/v1/author/works/${encodeURIComponent(authorState.activeWorkId)}/submit`, { + method: "POST", + body: JSON.stringify({ + account_id: activeAuthorAccountId() || null, + }), + }); + authorState.activeWorkDetail = payload; + authorState.authorWorkDiagnostics = payload?.diagnostics_summary || payload?.diagnostics_summary_json || null; + await refreshAuthorWorks(activeAuthorAccountId()); + renderAuthorReports(); + } catch (error) { + if (handleAuthorWorkAccessError(error, "送审作品稿")) { + return; + } + throw error; + } +} + +function jumpToAuthorChapter(chapterIndex, panelKey = "simulation") { + const normalized = Math.max(1, Number(chapterIndex || 0)); + if (!normalized) return; + authorState.selectedAuthorSimulationChapterIndex = normalized; + authorState.selectedAuthorContinuityChapterIndex = normalized; + renderAuthorReports(); + focusAuthorPanel(panelKey); +} + +function activeAuthorReviewerId() { + return ( + dom.authorInboxReviewerId?.value.trim() || + (authorSessionCanReview() ? authorState.authorAuthSession.identity.actor_id : "") || + dom.authorApprovalReviewer?.value.trim() || + "" + ); +} + +function authorSessionCanReview() { + return ["reviewer", "ops", "admin", "editor"].includes( + String(authorState.authorAuthSession?.identity?.actor_role || "").trim() + ); +} + +function hasAuthorAuthenticatedSession() { + return Boolean( + authorState.authorAuthSession?.accessToken || + (authorState.authorAuthSession?.cookieBacked && authorState.authorAuthSession?.identity) + ); +} + +function syncReviewerWorkbenchDefaults() { + const actorId = String(authorState.authorAuthSession?.identity?.actor_id || "").trim(); + if (!actorId || !authorSessionCanReview()) return; + if (dom.authorInboxReviewerId && !dom.authorInboxReviewerId.value.trim()) { + dom.authorInboxReviewerId.value = actorId; + } + if (dom.authorApprovalReviewer && !dom.authorApprovalReviewer.value.trim()) { + dom.authorApprovalReviewer.value = actorId; + } +} + +async function syncReaderMirrorFromAuthorSession() { + if (typeof ReaderRuntime !== "undefined" && typeof ReaderRuntime.mirrorReaderAuthSession === "function") { + await ReaderRuntime.mirrorReaderAuthSession(authorState.authorAuthSession); + } +} + +function activeAuthorActorId(options = {}) { + if (options.preferReviewer) { + return activeAuthorReviewerId() || dom.authorAccountId?.value.trim() || ""; + } + return authorState.authorAuthSession?.identity?.actor_id || dom.authorAccountId?.value.trim() || activeAuthorReviewerId() || ""; +} + +function activeAuthorActorRole(actorId = activeAuthorActorId()) { + if (authorState.authorAuthSession?.identity?.actor_role) { + return authorState.authorAuthSession.identity.actor_role; + } + const draftAuthorId = authorState.activeDraftDetail?.worldpack?.manifest?.author_id || ""; + return actorId && draftAuthorId && actorId === draftAuthorId ? "author" : "reviewer"; +} + +function currentAuthorInboxFilters() { + return { + reviewerId: activeAuthorReviewerId(), + statusFilter: dom.authorInboxStatusFilter?.value || "all", + worldVersionId: dom.authorInboxWorldVersionFilter?.value.trim() || "", + notificationType: dom.authorInboxNotificationTypeFilter?.value || "", + blockingOnly: Boolean(dom.authorInboxBlockingOnly?.checked), + query: (dom.authorInboxSearch?.value || "").trim(), + }; +} + +function authorCollaborationHeaders(options = {}) { + void options; + const token = String(authorState.authorAuthSession?.accessToken || "").trim(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +async function selectAuthorThread(threadId, worldVersionId = "") { + authorState.selectedAuthorThreadId = threadId || null; + if (worldVersionId && worldVersionId !== authorState.activeDraftVersionId) { + authorState.activeDraftVersionId = worldVersionId; + await refreshAuthorSurface(); + return; + } + renderAuthorReports(); + focusAuthorPanel("collaboration"); +} + +function mergeAuthorReviewerInbox(existing, nextPayload) { + if (!existing) { + return nextPayload; + } + const mergedNotifications = [...(existing.notifications || []), ...(nextPayload.notifications || [])]; + const seen = new Set(); + const uniqueNotifications = []; + for (const item of mergedNotifications) { + if (!item?.notification_id || seen.has(item.notification_id)) continue; + seen.add(item.notification_id); + uniqueNotifications.push(item); + } + return { + ...existing, + filters: nextPayload.filters || existing.filters, + has_more: nextPayload.has_more, + next_cursor: nextPayload.next_cursor, + returned_count: uniqueNotifications.length, + notifications: uniqueNotifications, + unread_notifications: uniqueNotifications.filter((item) => item.status === "unread"), + }; +} + +function syncAuthorNotificationPreferenceInputs() { + const targetType = dom.authorNotificationPrefType?.value || "thread_assigned"; + const preferences = authorState.authorNotificationPreferences?.preferences || []; + const selected = preferences.find((item) => item.notification_type === targetType); + if (dom.authorNotificationPrefInApp) { + dom.authorNotificationPrefInApp.checked = selected ? Boolean(selected.in_app_enabled) : true; + } + if (dom.authorNotificationPrefAsync) { + dom.authorNotificationPrefAsync.checked = selected ? Boolean(selected.async_mirror_enabled) : true; + } + if (dom.authorNotificationPrefSink) { + dom.authorNotificationPrefSink.value = selected?.async_sink_name || "default"; + } + if (dom.authorNotificationPrefTarget) { + dom.authorNotificationPrefTarget.value = selected?.delivery_target || ""; + } +} + +function persistAuthorAuthSession() { + if (typeof window === "undefined") return; + if (authorState.authorAuthSession?.accessToken || (authorState.authorAuthSession?.cookieBacked && authorState.authorAuthSession?.identity)) { + window.localStorage.setItem("narrativeos_author_auth", JSON.stringify(authorState.authorAuthSession)); + } else { + window.localStorage.removeItem("narrativeos_author_auth"); + } +} + +function clearAuthorAuthSessionLocal() { + authorState.authorAuthSession = null; + persistAuthorAuthSession(); + renderAuthorAuthStatus(); +} + +function restoreAuthorAuthSession() { + if (typeof window === "undefined") return; + try { + const raw = window.localStorage.getItem("narrativeos_author_auth"); + if (!raw) { + if (authorState.authorAuthSession?.identity) return; + authorState.authorAuthSession = null; + return; + } + authorState.authorAuthSession = JSON.parse(raw); + } catch (_error) { + authorState.authorAuthSession = null; + } +} + +function renderAuthorAuthStatus() { + clearNode(dom.authorAuthStatus); + const session = authorState.authorAuthSession; + if (!session?.identity) { + clearNode(dom.authorAuthStatus, "这里会显示当前账号信息与登录状态。"); + return; + } + dom.authorAuthStatus.appendChild( + createListCard({ + title: `${session.identity.display_name || session.identity.actor_id || "-"} · 已登录`, + score: "账号状态", + body: + `账号 ${session.identity.account_id || "-"}\n` + + `显示名称 ${session.identity.display_name || "-"}\n` + + `登录有效期 ${session.expiresAt || "-"}` + }) + ); +} + +async function refreshAuthorReviewerInbox(options = {}) { + const { reviewerId, statusFilter, worldVersionId, notificationType, blockingOnly, query: searchQuery } = currentAuthorInboxFilters(); + if (!hasAuthorAuthenticatedSession() || !authorSessionCanReview() || !reviewerId) { + authorState.authorReviewerInbox = null; + authorState.authorReviewerInboxNextCursor = null; + authorState.authorReviewerInboxHasMore = false; + return; + } + const params = new URLSearchParams(); + params.set("reviewer_id", reviewerId); + params.set("limit", "12"); + params.set("status_filter", statusFilter); + if (worldVersionId) { + params.set("world_version_id", worldVersionId); + } + if (notificationType) { + params.set("notification_type", notificationType); + } + if (blockingOnly) { + params.set("blocking_only", "true"); + } + if (searchQuery) { + params.set("q", searchQuery); + } + if (options.cursor) { + params.set("cursor", options.cursor); + } + const payload = await api(`/v1/author/reviewer-inbox?${params.toString()}`, { + headers: authorCollaborationHeaders({ preferReviewer: true }), + }); + authorState.authorReviewerInbox = options.append ? mergeAuthorReviewerInbox(authorState.authorReviewerInbox, payload) : payload; + authorState.authorReviewerInboxNextCursor = payload.next_cursor || null; + authorState.authorReviewerInboxHasMore = Boolean(payload.has_more); + authorState.authorReviewerInboxSearch = searchQuery; +} + +async function updateAuthorThreadStatusInline(threadId, status, options = {}) { + const body = options.body || ""; + const actorId = options.actorId || activeAuthorActorId(); + await api(`/v1/author/comments/${encodeURIComponent(threadId)}/status`, { + method: "POST", + headers: authorCollaborationHeaders({ + actorId, + actorRole: options.actorRole || activeAuthorActorRole(actorId), + }), + body: JSON.stringify({ + status, + assignee_id: options.assigneeId === undefined ? undefined : options.assigneeId, + actor_id: actorId, + actor_role: options.actorRole || activeAuthorActorRole(actorId), + body: body || undefined, + }), + }); + await refreshAuthorSurface(); + focusAuthorPanel("collaboration"); +} + +async function updateAuthorNotificationStatus(notificationId, status) { + if (!hasAuthorAuthenticatedSession()) { + authorNotice("请先登录当前通知所属账号。", "warning"); + return; + } + await api(`/v1/author/notifications/${encodeURIComponent(notificationId)}/status`, { + method: "POST", + headers: authorCollaborationHeaders({ preferReviewer: true }), + body: JSON.stringify({ + status, + recipient_id: activeAuthorReviewerId(), + limit: 12, + }), + }); + await refreshAuthorSurface(); + focusAuthorPanel("collaboration"); +} + +async function bulkUpdateAuthorNotificationStatus(status) { + if (!hasAuthorAuthenticatedSession()) { + authorNotice("请先登录当前通知所属账号。", "warning"); + return; + } + const notificationIds = authorState.authorReviewerInboxVisibleNotificationIds || []; + if (!notificationIds.length) { + authorNotice("当前没有可批量处理的 notifications。"); + return; + } + await api("/v1/author/notifications/bulk-status", { + method: "POST", + headers: authorCollaborationHeaders({ preferReviewer: true }), + body: JSON.stringify({ + notification_ids: notificationIds, + recipient_id: activeAuthorReviewerId(), + status, + limit: 12, + }), + }); + await refreshAuthorSurface(); + focusAuthorPanel("collaboration"); +} + +async function decideAuthorApprovalForWorld(worldVersionId, status, reviewerId, reason) { + await api(`/v1/author/drafts/${encodeURIComponent(worldVersionId)}/approval/decision`, { + method: "POST", + headers: authorCollaborationHeaders({ actorId: reviewerId || activeAuthorReviewerId(), actorRole: "reviewer" }), + body: JSON.stringify({ + reviewer_id: reviewerId || activeAuthorReviewerId(), + status, + reason: reason || (status === "approved" ? "Reviewer inbox 快速批准。" : "Reviewer inbox 要求修改。"), + }), + }); + await refreshAuthorSurface(); + focusAuthorPanel("collaboration"); +} + +async function addAuthorThreadWatcher(threadId, watcherId = "") { + const actorId = activeAuthorActorId(); + await api(`/v1/author/comments/${encodeURIComponent(threadId)}/watchers`, { + method: "POST", + headers: authorCollaborationHeaders({ actorId, actorRole: activeAuthorActorRole(actorId) }), + body: JSON.stringify({ + actor_id: actorId, + watcher_id: watcherId || actorId, + }), + }); + await refreshAuthorSurface(); + focusAuthorPanel("collaboration"); +} + +async function removeAuthorThreadWatcher(threadId, watcherId) { + const actorId = activeAuthorActorId(); + await api(`/v1/author/comments/${encodeURIComponent(threadId)}/watchers/${encodeURIComponent(watcherId)}/remove`, { + method: "POST", + headers: authorCollaborationHeaders({ actorId, actorRole: activeAuthorActorRole(actorId) }), + body: JSON.stringify({ + actor_id: actorId, + watcher_id: watcherId, + }), + }); + await refreshAuthorSurface(); + focusAuthorPanel("collaboration"); +} + +async function replyToSelectedAuthorThread(threadId) { + const body = (authorState.authorInlineReplyDraft || "").trim(); + if (!body) { + authorNotice("先写回复内容。"); + return; + } + const actorId = activeAuthorActorId(); + await api(`/v1/author/comments/${encodeURIComponent(threadId)}/reply`, { + method: "POST", + headers: authorCollaborationHeaders({ actorId, actorRole: activeAuthorActorRole(actorId) }), + body: JSON.stringify({ + actor_id: actorId, + actor_role: activeAuthorActorRole(actorId), + body, + }), + }); + authorState.authorInlineReplyDraft = ""; + await refreshAuthorSurface(); + focusAuthorPanel("collaboration"); +} + +async function addAuthorDraftWatcher() { + if (!authorState.activeDraftVersionId) { + authorNotice("先选择一个 draft。"); + return; + } + const watcherId = (dom.authorDraftWatcherId?.value || "").trim() || activeAuthorActorId(); + const actorId = activeAuthorActorId(); + await api(`/v1/author/drafts/${encodeURIComponent(authorState.activeDraftVersionId)}/watchers`, { + method: "POST", + headers: authorCollaborationHeaders({ actorId, actorRole: activeAuthorActorRole(actorId) }), + body: JSON.stringify({ + actor_id: actorId, + watcher_id: watcherId, + }), + }); + await refreshAuthorSurface(); + focusAuthorPanel("collaboration"); +} + +async function removeAuthorDraftWatcher() { + if (!authorState.activeDraftVersionId) { + authorNotice("先选择一个 draft。"); + return; + } + const watcherId = (dom.authorDraftWatcherId?.value || "").trim(); + if (!watcherId) { + authorNotice("先填写 draft watcher id。"); + return; + } + const actorId = activeAuthorActorId(); + await api(`/v1/author/drafts/${encodeURIComponent(authorState.activeDraftVersionId)}/watchers/${encodeURIComponent(watcherId)}/remove`, { + method: "POST", + headers: authorCollaborationHeaders({ actorId, actorRole: activeAuthorActorRole(actorId) }), + body: JSON.stringify({ + actor_id: actorId, + watcher_id: watcherId, + }), + }); + await refreshAuthorSurface(); + focusAuthorPanel("collaboration"); +} + +async function refreshAuthorNotificationPreferences() { + const actorId = activeAuthorActorId(); + if (!actorId || !hasAuthorAuthenticatedSession()) { + authorState.authorNotificationPreferences = null; + return; + } + authorState.authorNotificationPreferences = await api( + `/v1/author/notification-preferences?actor_id=${encodeURIComponent(actorId)}`, + { + headers: authorCollaborationHeaders({ actorId, actorRole: activeAuthorActorRole(actorId) }), + } + ); + syncAuthorNotificationPreferenceInputs(); +} + +async function saveAuthorNotificationPreference() { + const actorId = activeAuthorActorId(); + if (!actorId || !hasAuthorAuthenticatedSession()) { + authorNotice("请先登录要更新通知设置的账号。", "warning"); + return; + } + await api("/v1/author/notification-preferences", { + method: "POST", + headers: authorCollaborationHeaders({ actorId, actorRole: activeAuthorActorRole(actorId) }), + body: JSON.stringify({ + actor_id: actorId, + notification_type: dom.authorNotificationPrefType?.value || "thread_assigned", + in_app_enabled: Boolean(dom.authorNotificationPrefInApp?.checked), + async_mirror_enabled: Boolean(dom.authorNotificationPrefAsync?.checked), + async_sink_name: dom.authorNotificationPrefSink?.value || "default", + delivery_target: (dom.authorNotificationPrefTarget?.value || "").trim() || null, + }), + }); + await refreshAuthorNotificationPreferences(); + renderAuthorReports(); +} + +async function registerAuthorAuthIdentity() { + const actorId = (dom.authorAuthActorId?.value || "").trim() || activeAuthorActorId(); + const password = (dom.authorAuthPassword?.value || "").trim(); + if (!actorId || !password) { + authorNotice("请先填写 actor id 和 password。"); + return; + } + await api("/v1/auth/register", { + method: "POST", + body: JSON.stringify({ + actor_id: actorId, + actor_role: dom.authorAuthRole?.value || "author", + password, + account_id: dom.authorAccountId?.value.trim() || actorId, + display_name: (dom.authorAuthDisplayName?.value || "").trim() || null, + }), + }); + if (String(actorId).includes("@")) { + authorNotice("注册已提交。请先检查验证邮件,完成验证后再登录。", "success"); + return; + } + await loginAuthorAuthIdentity(); +} + +async function loginAuthorAuthIdentity() { + const actorId = (dom.authorAuthActorId?.value || "").trim() || activeAuthorActorId(); + const password = (dom.authorAuthPassword?.value || "").trim(); + if (!actorId || !password) { + authorNotice("请先填写 actor id 和 password。"); + return; + } + const payload = await api("/v1/auth/login", { + method: "POST", + body: JSON.stringify({ + actor_id: actorId, + password, + }), + }); + authorState.authorAuthSession = { + accessToken: payload.token?.access_token, + expiresAt: payload.token?.expires_at, + identity: payload.identity, + tokenType: payload.token?.token_type || "bearer", + cookieBacked: false, + }; + persistAuthorAuthSession(); + await syncReaderMirrorFromAuthorSession(); + syncReviewerWorkbenchDefaults(); + if (dom.authorAccountId && payload.identity?.account_id) { + dom.authorAccountId.value = payload.identity.account_id; + } + if (authorSessionCanReview()) { + shellState.activeProduct = "author"; + shellState.authorWorkspace = "settings"; + } + if (resumeAuthorDeepLinkIfPossible(payload.identity?.account_id || "")) { + return; + } + renderAuthorAuthStatus(); + await refreshAuthorSurface(); + if (typeof ShellStatusRuntime !== "undefined") { + ShellStatusRuntime.syncProductMode(); + ShellStatusRuntime.updateStatus(); + } + if (typeof ReaderRuntime !== "undefined" && typeof ReaderRuntime.refreshAuthoredWorkLibrary === "function") { + ReaderRuntime.refreshAuthoredWorkLibrary().catch(() => {}); + } +} + +async function hydrateAuthorAuthSession() { + const existingSession = authorState.authorAuthSession; + if (!existingSession?.accessToken && !existingSession?.cookieBacked && !existingSession?.identity) { + clearAuthorAuthSessionLocal(); + await syncReaderMirrorFromAuthorSession(); + syncReviewerWorkbenchDefaults(); + if (typeof ShellStatusRuntime !== "undefined") { + ShellStatusRuntime.syncProductMode(); + ShellStatusRuntime.updateStatus(); + } + return; + } + try { + const payload = await api( + "/v1/auth/me", + existingSession?.accessToken + ? { headers: { Authorization: `Bearer ${existingSession.accessToken}` } } + : {} + ); + authorState.authorAuthSession = { + ...existingSession, + accessToken: existingSession?.accessToken || null, + identity: payload.identity, + expiresAt: payload.identity?.expires_at || existingSession?.expiresAt || null, + tokenType: existingSession?.tokenType || "bearer", + cookieBacked: !existingSession?.accessToken, + }; + persistAuthorAuthSession(); + } catch (error) { + clearAuthorAuthSessionLocal(); + } + if (dom.authorAccountId && authorState.authorAuthSession?.identity?.account_id) { + dom.authorAccountId.value = authorState.authorAuthSession.identity.account_id; + } + if ( + shellState.startupRouteProduct === "ops" && + ["reviewer", "ops", "admin"].includes(String(authorState.authorAuthSession?.identity?.actor_role || "").trim()) + ) { + shellState.activeProduct = "ops"; + if (shellState.startupRouteWorkspace) { + shellState.opsWorkspace = shellState.startupRouteWorkspace; + } + } + await syncReaderMirrorFromAuthorSession(); + syncReviewerWorkbenchDefaults(); + renderAuthorAuthStatus(); + if (typeof ShellStatusRuntime !== "undefined") { + ShellStatusRuntime.syncProductMode(); + ShellStatusRuntime.updateStatus(); + } +} + +async function logoutAuthorAuthIdentity() { + if (!authorState.authorAuthSession?.accessToken) { + clearAuthorAuthSessionLocal(); + await syncReaderMirrorFromAuthorSession(); + if (typeof ShellStatusRuntime !== "undefined") { + ShellStatusRuntime.syncProductMode(); + ShellStatusRuntime.updateStatus(); + } + return; + } + try { + await api("/v1/auth/logout", { method: "POST" }); + } catch (_error) { + // Even if logout fails remotely, clear local session for safety. + } + clearAuthorAuthSessionLocal(); + await syncReaderMirrorFromAuthorSession(); + if (typeof ShellStatusRuntime !== "undefined") { + ShellStatusRuntime.syncProductMode(); + ShellStatusRuntime.updateStatus(); + } + if (typeof ReaderRuntime !== "undefined" && typeof ReaderRuntime.refreshAuthoredWorkLibrary === "function") { + ReaderRuntime.refreshAuthoredWorkLibrary().catch(() => {}); + } +} + +async function validateDraftVersion(worldVersionId) { + const detail = await api(`/v1/author/drafts/${worldVersionId}`); + const report = await api("/v1/author/drafts/validate", { + method: "POST", + body: JSON.stringify({ + worldpack: detail.worldpack, + account_id: dom.authorAccountId?.value.trim() || "web_author", + }), + }); + authorState.activeDraftVersionId = worldVersionId; + authorState.activeDraftDetail = detail; + authorState.selectedAuthorRevisionIndex = null; + authorState.authorValidationReport = report; + authorState.authorWorkflowSummary = null; + await refreshAuthorSurface(); + focusAuthorPanel("validation"); + return report; +} + +async function simulateDraftVersion(worldVersionId, options = {}) { + authorState.activeDraftDetail = await api(`/v1/author/drafts/${worldVersionId}`); + authorState.authorPreviousSimulationReport = currentAuthorSimulationReport(); + const interactiveScenarios = Array.isArray(options.interactiveScenarios) + ? options.interactiveScenarios.filter(Boolean) + : []; + const payload = { + ...(options.accountId ? { account_id: options.accountId } : {}), + ...(interactiveScenarios.length ? { interactive_scenarios: interactiveScenarios } : {}), + }; + const report = await api( + `/v1/author/drafts/${worldVersionId}/simulate`, + Object.keys(payload).length + ? { method: "POST", body: JSON.stringify(payload) } + : { method: "POST" } + ); + authorState.activeDraftVersionId = worldVersionId; + authorState.authorSimulationReport = report; + authorState.activeDraftDetail = await api(`/v1/author/drafts/${worldVersionId}`); + authorState.selectedAuthorRevisionIndex = null; + authorState.authorWorkflowSummary = null; + await refreshAuthorSurface(); + await refreshOpsSurfaceIfVisible(); + focusAuthorPanel("simulation"); + return report; +} + +function currentAuthorSimulationReport() { + return authorState.activeDraftDetail?.simulation_report || authorState.authorSimulationReport || null; +} + +function currentAuthorCreativeCockpit() { + return currentAuthorSimulationReport()?.creative_cockpit || authorState.activeDraftDetail?.creative_cockpit || null; +} + +function selectedMultiValues(selectNode) { + return Array.from(selectNode?.selectedOptions || []) + .map((option) => String(option.value || "").trim()) + .filter(Boolean); +} + +function setMultiSelectValues(selectNode, values) { + const selected = new Set((values || []).map((item) => String(item))); + Array.from(selectNode?.options || []).forEach((option) => { + option.selected = selected.has(String(option.value || "")); + }); +} + +function openAuthorCharacterAsset(characterId) { + const normalized = String(characterId || "").trim(); + const characters = getActiveDraftCharacters(); + const index = characters.findIndex((character) => String(character.character_id || "") === normalized); + WorkspaceLayoutRuntime.setAuthorWorkspace("draft", { silent: true }); + setAuthorDraftSection("assets", { silent: true }); + ShellStatusRuntime.syncProductMode(); + if (!characters.length) { + authorNotice("当前没有可编辑角色,已切到角色卡编辑区。"); + focusAuthorPanel("character_editor"); + return; + } + const fallbackIndex = index >= 0 ? index : 0; + if (dom.authorCharacterSelect) { + dom.authorCharacterSelect.value = String(fallbackIndex); + } + renderCharacterEditor(); + if (!normalized || index < 0) { + authorNotice("当前热点没有对应到精确角色卡,已打开角色卡编辑区。"); + } + focusAuthorPanel("character_editor"); +} + +function openAuthorSceneAsset({ sceneId = "", sceneFunction = "" } = {}) { + const normalizedSceneId = String(sceneId || "").trim(); + const normalizedSceneFunction = String(sceneFunction || "").trim(); + const scenes = getActiveDraftScenes(); + const index = scenes.findIndex((scene) => { + const currentSceneId = String(scene.scene_id || ""); + const currentSceneFunction = String(scene.scene_function || ""); + return ( + (normalizedSceneId && currentSceneId === normalizedSceneId) || + (!normalizedSceneId && normalizedSceneFunction && currentSceneFunction === normalizedSceneFunction) + ); + }); + WorkspaceLayoutRuntime.setAuthorWorkspace("draft", { silent: true }); + setAuthorDraftSection("assets", { silent: true }); + ShellStatusRuntime.syncProductMode(); + if (!scenes.length) { + authorNotice("当前没有可编辑场景,已切到场景蓝图编辑区。"); + focusAuthorPanel("scene_editor"); + return; + } + const fallbackIndex = index >= 0 ? index : 0; + if (dom.authorSceneSelect) { + dom.authorSceneSelect.value = String(fallbackIndex); + } + renderSceneEditor(); + if (index < 0) { + authorNotice("当前热点没有对应到精确的 scene blueprint,已打开场景蓝图编辑区。"); + } + focusAuthorPanel("scene_editor"); +} + +function openAuthorTaskAsset({ volumeId = "", arcId = "", taskId = "" } = {}) { + const volumePlans = getActiveVolumePlans(); + const arcPlans = getActiveArcPlans(); + let targetArc = null; + let targetVolume = null; + let targetTaskId = String(taskId || "").trim(); + + if (targetTaskId) { + targetArc = arcPlans.find((arc) => (arc.chapter_tasks || []).some((task) => String(task.chapter_task_id || "") === targetTaskId)) || null; + } + if (!targetArc && arcId) { + targetArc = arcPlans.find((arc) => String(arc.arc_id || "") === String(arcId)) || null; + } + if (!targetArc && volumeId) { + targetArc = arcPlans.find((arc) => String(arc.volume_id || "") === String(volumeId)) || null; + } + targetVolume = + volumePlans.find((volume) => String(volume.volume_id || "") === String(volumeId || targetArc?.volume_id || "")) || + volumePlans[0] || + null; + if (!targetArc && targetVolume) { + targetArc = arcPlans.find((arc) => String(arc.volume_id || "") === String(targetVolume.volume_id || "")) || null; + } + if (!targetTaskId && targetArc) { + targetTaskId = String(((targetArc.chapter_tasks || [])[0] || {}).chapter_task_id || ""); + } + authorState.selectedAuthorVolumeId = targetVolume?.volume_id || null; + authorState.selectedAuthorArcId = targetArc?.arc_id || null; + authorState.selectedAuthorTaskId = targetTaskId || null; + if (dom.authorTaskBulkIssues) dom.authorTaskBulkIssues.value = ""; + if (dom.authorTaskBulkNotes) dom.authorTaskBulkNotes.value = ""; + WorkspaceLayoutRuntime.setAuthorWorkspace("draft", { silent: true }); + setAuthorDraftSection("longform", { silent: true }); + ShellStatusRuntime.syncProductMode(); + if (!targetVolume || !targetArc) { + authorNotice("当前热点没有对应到精确的 chapter task,已切到长篇规划编辑区。"); + focusAuthorPanel("longform"); + return; + } + renderLongformWorkbench(); + focusAuthorPanel("task_editor"); +} + +function setAuthorRepairLoopHint(payload) { + authorState.authorRepairLoopHint = payload ? { ...payload } : null; +} + +function currentAuthorRepairLoopResult() { + return authorState.activeDraftDetail?.latest_repair_loop_outcome || currentAuthorSimulationReport()?.latest_repair_loop_outcome || null; +} + +async function refreshOpsSurfaceIfVisible() { + if (shellState.activeProduct !== "ops") { + return; + } + await refreshOpsSurface(); +} + +function normalizeAuthorRepairLoopState(payload) { + if (!payload) return null; + const targetedChapters = Array.isArray(payload.targetedChapters) + ? payload.targetedChapters + : Array.isArray(payload.targeted_chapters) + ? payload.targeted_chapters + : []; + return { + available: Boolean(payload.available), + repairLoopRevisionId: payload.repairLoopRevisionId || payload.repair_loop_revision_id || "", + issueCode: payload.issueCode || payload.issue_code || "", + issueLabel: payload.issueLabel || payload.issue_label || payload.issueCode || payload.issue_code || "", + assetType: payload.assetType || payload.asset_type || "", + assetLabel: payload.assetLabel || payload.asset_label || "", + targetLabel: payload.targetLabel || payload.target_label || "", + validationPanel: payload.validationPanel || payload.validation_panel || "", + validationPanelLabel: payload.validationPanelLabel || payload.validation_panel_label || "", + validationReason: payload.validationReason || payload.validation_reason || "", + chapterIndex: Number(payload.chapterIndex || payload.chapter_index || 0) || null, + chapterTitle: payload.chapterTitle || payload.chapter_title || "", + characterId: payload.characterId || payload.character_id || "", + sceneId: payload.sceneId || payload.scene_id || "", + sceneFunction: payload.sceneFunction || payload.scene_function || "", + chapterTaskId: payload.chapterTaskId || payload.chapter_task_id || "", + arcId: payload.arcId || payload.arc_id || "", + volumeId: payload.volumeId || payload.volume_id || "", + targetedChapters, + baselineIssueCount: payload.baselineIssueCount ?? payload.baseline_issue_count ?? null, + currentIssueCount: payload.currentIssueCount ?? payload.current_issue_count ?? null, + baselineTargetedIssueCount: payload.baselineTargetedIssueCount ?? payload.baseline_targeted_issue_count ?? null, + currentTargetedIssueCount: payload.currentTargetedIssueCount ?? payload.current_targeted_issue_count ?? null, + countDelta: payload.countDelta ?? payload.count_delta ?? null, + baselineWorstDecision: payload.baselineWorstDecision || payload.baseline_worst_decision || "", + currentWorstDecision: payload.currentWorstDecision || payload.current_worst_decision || "", + severityTrend: payload.severityTrend || payload.severity_trend || "", + resolvedChapters: payload.resolvedChapters || payload.resolved_chapters || [], + remainingChapters: payload.remainingChapters || payload.remaining_chapters || [], + readyForValidation: Boolean(payload.readyForValidation ?? payload.ready_for_validation), + }; +} + +function currentAuthorRepairLoopState() { + const outcome = normalizeAuthorRepairLoopState(currentAuthorRepairLoopResult()); + const hint = normalizeAuthorRepairLoopState(authorState.authorRepairLoopHint); + if (hint && !hint.available) { + const sameLoop = Boolean( + outcome?.available && + ( + (hint.repairLoopRevisionId && outcome.repairLoopRevisionId === hint.repairLoopRevisionId) || + ( + !hint.repairLoopRevisionId && + outcome.issueCode === hint.issueCode && + outcome.assetType === hint.assetType && + (outcome.targetLabel || "") === (hint.targetLabel || "") + ) + ) + ); + return sameLoop ? outcome : hint; + } + if (outcome?.available) return outcome; + return hint; +} + +function buildAuthorRepairLoopHint(priority, issueGroup = null) { + if (!priority) return null; + const firstChapter = (issueGroup?.chapters || [])[0] || {}; + return { + issueCode: issueGroup?.issue_code || "", + issueLabel: issueGroup?.label || issueGroup?.issue_code || "", + assetType: priority.asset_type || "", + assetLabel: priority.label || "", + targetLabel: priority.target_label || "", + validationPanel: priority.validation_panel || issueGroup?.primary_validation_panel || "", + validationPanelLabel: priority.validation_panel_label || issueGroup?.primary_validation_panel_label || "", + validationReason: priority.validation_reason || "", + chapterIndex: Number(priority.chapter_index || firstChapter.chapter_index || 0) || null, + chapterTitle: priority.chapter_title || firstChapter.chapter_title || "", + characterId: priority.character_id || (priority.character_ids || [])[0] || (firstChapter.related_character_ids || [])[0] || "", + sceneId: priority.scene_id || firstChapter.scene_id || "", + sceneFunction: priority.scene_function || firstChapter.scene_function || "", + chapterTaskId: priority.chapter_task_id || firstChapter.chapter_task_id || "", + arcId: priority.arc_id || firstChapter.arc_id || "", + volumeId: priority.volume_id || firstChapter.volume_id || "", + targetedChapters: (issueGroup?.chapters || []).map((chapter) => ({ + chapter_index: chapter.chapter_index, + chapter_title: chapter.chapter_title || "", + })), + }; +} + +function openAuthorRepairLoopValidationPanel() { + const hint = currentAuthorRepairLoopState(); + if (!hint?.validationPanel) { + authorNotice("当前没有可返回的复核面板。"); + return; + } + if (hint.validationPanel === "compare") { + if (hint.chapterIndex) { + jumpToAuthorChapter(hint.chapterIndex, "compare"); + } else { + focusAuthorPanel("compare"); + } + return; + } + if (hint.validationPanel === "continuity") { + if (hint.chapterIndex) { + authorState.selectedAuthorContinuityChapterIndex = hint.chapterIndex; + renderAuthorReports(); + } + focusAuthorPanel("continuity"); + return; + } + if (hint.validationPanel === "task_linking") { + if (hint.volumeId) authorState.selectedAuthorVolumeId = hint.volumeId; + if (hint.arcId) authorState.selectedAuthorArcId = hint.arcId; + if (hint.chapterTaskId) authorState.selectedAuthorTaskId = hint.chapterTaskId; + renderAuthorReports(); + focusAuthorPanel("task_linking"); + return; + } + authorNotice("当前复核面板类型还没有接入。"); +} + +function createRepairLoopSummaryCard(expectedAssetType) { + const loopState = currentAuthorRepairLoopState(); + if (!loopState || loopState.assetType !== expectedAssetType) return null; + if (loopState.available) { + return createAuthorSummaryCard({ + title: `${loopState.issueCode || "修稿回路"} 结果`, + score: loopState.severityTrend || "-", + body: + `当前资产 ${loopState.assetLabel || loopState.assetType}${loopState.targetLabel ? ` -> ${loopState.targetLabel}` : ""}\n` + + `issue count ${loopState.baselineIssueCount ?? loopState.baseline_issue_count ?? "-"} -> ${loopState.currentIssueCount ?? loopState.current_issue_count ?? "-"}\n` + + `worst decision ${loopState.baselineWorstDecision ?? loopState.baseline_worst_decision ?? "-"} -> ${loopState.currentWorstDecision ?? loopState.current_worst_decision ?? "-"}\n` + + `改完后回 ${loopState.validationPanelLabel || loopState.validation_panel_label || loopState.validationPanel || loopState.validation_panel || "-"} 复核\n` + + `${loopState.validationReason || loopState.validation_reason || "回修稿桥确认问题是否消退。"}`, + actionLabel: `${loopState.readyForValidation || loopState.ready_for_validation ? "去复核" : "先看结果"}`, + onAction: openAuthorRepairLoopValidationPanel, + primary: true, + }); + } + return createAuthorSummaryCard({ + title: `${loopState.issueCode || "修稿回路"} 复核提示`, + score: loopState.validationPanelLabel || "-", + body: + `当前资产 ${loopState.assetLabel || loopState.assetType}${loopState.targetLabel ? ` -> ${loopState.targetLabel}` : ""}\n` + + `热点章节 ${loopState.chapterIndex ? `#${loopState.chapterIndex} ${loopState.chapterTitle || ""}` : "-"}\n` + + `改完后回 ${loopState.validationPanelLabel || loopState.validationPanel || "-"} 复核\n` + + `${loopState.validationReason || "回修稿桥确认问题是否消退。"}`, + actionLabel: `回${loopState.validationPanelLabel || loopState.validationPanel || "修稿桥"}复核`, + onAction: openAuthorRepairLoopValidationPanel, + primary: true, + }); +} + +function buildDraftChangeContext(source, label, expectedAssetType = "") { + const changeContext = { source, label }; + const loopState = currentAuthorRepairLoopState(); + if (!expectedAssetType || !loopState || loopState.assetType !== expectedAssetType) { + return changeContext; + } + changeContext.repair_loop_context = { + issue_code: loopState.issueCode || "", + issue_label: loopState.issueLabel || loopState.issueCode || "", + asset_type: loopState.assetType || "", + asset_label: loopState.assetLabel || "", + target_label: loopState.targetLabel || "", + validation_panel: loopState.validationPanel || "", + validation_panel_label: loopState.validationPanelLabel || "", + validation_reason: loopState.validationReason || "", + character_id: loopState.characterId || "", + scene_id: loopState.sceneId || "", + scene_function: loopState.sceneFunction || "", + chapter_task_id: loopState.chapterTaskId || "", + arc_id: loopState.arcId || "", + volume_id: loopState.volumeId || "", + chapter_index: loopState.chapterIndex || null, + chapter_title: loopState.chapterTitle || "", + targeted_chapters: loopState.targetedChapters || [], + }; + return changeContext; +} + +function syncAuthorRepairLoopHintFromDraft(draft, expectedAssetType) { + const hint = normalizeAuthorRepairLoopState(authorState.authorRepairLoopHint); + if (!hint || hint.assetType !== expectedAssetType) return; + const revisions = Array.isArray(draft?.revision_history) ? draft.revision_history : []; + const latestRevision = revisions[revisions.length - 1] || null; + if (!latestRevision?.revision_id) return; + setAuthorRepairLoopHint({ + ...hint, + repairLoopRevisionId: latestRevision.revision_id, + }); +} + +function openAuthorPriorityAsset(priority, issueGroup = null) { + if (!priority || !priority.asset_type) { + authorNotice("当前没有可打开的推荐资产。"); + return; + } + setAuthorRepairLoopHint(buildAuthorRepairLoopHint(priority, issueGroup)); + if (priority.asset_type === "scene_blueprint") { + openAuthorSceneAsset({ sceneId: priority.scene_id, sceneFunction: priority.scene_function }); + return; + } + if (priority.asset_type === "chapter_task") { + openAuthorTaskAsset({ volumeId: priority.volume_id, arcId: priority.arc_id, taskId: priority.chapter_task_id }); + return; + } + if (priority.asset_type === "character_card") { + openAuthorCharacterAsset(priority.character_id || (priority.character_ids || [])[0] || ""); + return; + } + authorNotice("当前推荐资产类型还没有接入编辑器。"); +} + +function focusAuthorCreativeCockpit() { + focusAuthorPanel("creative_cockpit"); +} + +function clearAuthorSteeringComposer() { + if (dom.authorSteeringIntent) dom.authorSteeringIntent.value = ""; + if (dom.authorSteeringType) dom.authorSteeringType.value = "mild_steer"; + if (dom.authorSteeringMemoryPatch) dom.authorSteeringMemoryPatch.value = ""; + if (dom.authorSteeringArc) dom.authorSteeringArc.value = ""; + setMultiSelectValues(dom.authorSteeringCharacters, []); + authorState.pendingAuthorBranchSeed = null; +} + +function renderAuthorSteeringComposer() { + if (!dom.authorSteeringComposer || !dom.authorSteeringStatus) return; + const activeDraft = authorState.activeDraftDetail; + const simulationReport = currentAuthorSimulationReport(); + const cockpit = currentAuthorCreativeCockpit() || {}; + const steeringTimeline = cockpit.steering_timeline || {}; + const relationshipHotspot = (cockpit.relationship_hotspots?.items || [])[0] || null; + const characters = getActiveDraftCharacters(); + const arcs = getActiveArcPlans(); + const previousCharacterSelection = new Set(selectedMultiValues(dom.authorSteeringCharacters)); + const previousArcValue = dom.authorSteeringArc?.value || ""; + const defaultTriggerChapter = Math.max(1, Number(simulationReport?.completed_chapters || 0) + 1); + const forkContext = resolveAuthorSteeringForkContext(defaultTriggerChapter); + + if (dom.authorSteeringCharacters) { + dom.authorSteeringCharacters.innerHTML = ""; + characters.forEach((character) => { + const option = document.createElement("option"); + option.value = String(character.character_id || ""); + option.textContent = character.display_name || character.character_id || "未命名角色"; + option.selected = previousCharacterSelection.has(option.value); + dom.authorSteeringCharacters.appendChild(option); + }); + dom.authorSteeringCharacters.disabled = !characters.length; + } + if (dom.authorSteeringArc) { + dom.authorSteeringArc.innerHTML = ""; + const autoOption = document.createElement("option"); + autoOption.value = ""; + autoOption.textContent = "自动跟随当前弧线"; + dom.authorSteeringArc.appendChild(autoOption); + arcs.forEach((arc) => { + const option = document.createElement("option"); + option.value = String(arc.arc_id || ""); + option.textContent = arc.title || arc.arc_id || "未命名弧线"; + option.selected = option.value === previousArcValue; + dom.authorSteeringArc.appendChild(option); + }); + if (previousArcValue && Array.from(dom.authorSteeringArc.options).every((option) => option.value !== previousArcValue)) { + dom.authorSteeringArc.value = ""; + } + dom.authorSteeringArc.disabled = !activeDraft; + } + + if (!activeDraft || !authorState.activeDraftVersionId) { + if (dom.authorRunSteeredSimulation) dom.authorRunSteeredSimulation.disabled = true; + if (dom.authorClearSteering) dom.authorClearSteering.disabled = true; + clearNode(dom.authorSteeringStatus, "先选择一个 draft,再把一条剧情引导、一个记忆补丁或一个弧线偏移打进下一轮 simulation。"); + return; + } + + if (dom.authorRunSteeredSimulation) dom.authorRunSteeredSimulation.disabled = false; + if (dom.authorClearSteering) dom.authorClearSteering.disabled = false; + clearNode(dom.authorSteeringStatus); + const workScopedFork = Boolean(authorState.activeWorkId && forkContext.sourceChapterIndex); + const contextCard = createListCard({ + title: "Steering Context", + score: workScopedFork ? `第 ${forkContext.sourceChapterIndex} 章后分叉` : `第 ${forkContext.nextBranchStartChapterIndex} 章`, + body: + `${workScopedFork ? `当前分叉点以你选中的章节为准:第 ${forkContext.sourceChapterIndex} 章\n` : `默认触发章节 ${defaultTriggerChapter}\n`}` + + `${authorState.activeWorkId ? `当前作品稿 ${authorState.activeWorkDetail?.branch_name || "主线"} · 当前分叉点章节 ${forkContext.sourceChapterIndex || "-"} · 新命运线会从第 ${forkContext.nextBranchStartChapterIndex} 章开始\n` : ""}` + + `${authorState.activeWorkId ? "主线后续章节不会复制到新宇宙,过去只作为共享历史保留。\n" : ""}` + + `已有 checkpoint ${steeringTimeline.checkpoint_count ?? 0} · replan ${steeringTimeline.replan_event_count ?? 0}\n` + + `memory pending ${steeringTimeline.memory_patch_summary?.pending_count ?? 0} · adopted ${steeringTimeline.memory_patch_summary?.adopted_count ?? 0}\n` + + `${relationshipHotspot ? `当前最紧绷关系 ${relationshipHotspot.source_label} -> ${relationshipHotspot.target_label} · ${relationshipHotspot.dominant_metric_label} ${Number(relationshipHotspot.dominant_metric_value || 0).toFixed(2)}` : "当前最紧绷关系 需要先跑一次 simulation 才会出现。"}`, + }); + dom.authorSteeringStatus.appendChild(contextCard); + if (authorState.activeWorkId) { + const branchExecution = buildAuthorBranchExecutionPresentation(); + const executionCard = createListCard({ + title: branchExecution.title, + score: branchExecution.score, + body: branchExecution.body, + }); + executionCard.classList.add("author-branch-status-card"); + executionCard.dataset.tone = branchExecution.tone || "neutral"; + dom.authorSteeringStatus.appendChild(executionCard); + } +} + +function buildAuthorSteeringScenario() { + const summary = dom.authorSteeringIntent?.value.trim() || ""; + if (!summary) { + authorNotice("先写一句你想让剧情偏过去的方向。"); + dom.authorSteeringIntent?.focus(); + return null; + } + const scenarioKind = dom.authorSteeringType?.value || "mild_steer"; + const impactedCharacterIds = selectedMultiValues(dom.authorSteeringCharacters); + const memoryPatchNote = dom.authorSteeringMemoryPatch?.value.trim() || ""; + const affectedArcId = dom.authorSteeringArc?.value || ""; + return { + scenario_kind: scenarioKind, + label: summary, + steering_directive: { + current_user_intent: summary, + summary, + impacted_character_ids: impactedCharacterIds, + ...(memoryPatchNote ? { memory_patch_note: memoryPatchNote } : {}), + ...(affectedArcId ? { affected_arc_id: affectedArcId } : {}), + }, + }; +} + +function resolveAuthorSteeringForkContext(defaultTriggerChapter = 1) { + const explicitSeed = Number(authorState.pendingAuthorBranchSeed?.sourceChapterIndex || 0) || 0; + if (explicitSeed > 0) { + return { + sourceChapterIndex: explicitSeed, + nextBranchStartChapterIndex: explicitSeed + 1, + source: "choice_seed", + }; + } + const selectedSimulationChapterIndex = Number(authorState.selectedAuthorSimulationChapterIndex || 0) || 0; + if (shellState.authorWorkspace === "simulate" && selectedSimulationChapterIndex > 0) { + return { + sourceChapterIndex: selectedSimulationChapterIndex, + nextBranchStartChapterIndex: selectedSimulationChapterIndex + 1, + source: "selected_simulation_chapter", + }; + } + const fallbackChapterIndex = Math.max(0, Number(defaultTriggerChapter || 1) - 1); + if (shellState.authorWorkspace === "simulate" && fallbackChapterIndex > 0) { + return { + sourceChapterIndex: fallbackChapterIndex, + nextBranchStartChapterIndex: fallbackChapterIndex + 1, + source: "default_trigger_previous_chapter", + }; + } + return { + sourceChapterIndex: 0, + nextBranchStartChapterIndex: Math.max(1, Number(defaultTriggerChapter || 1)), + source: "missing_simulation_context", + }; +} + +async function createAuthorWorkBranchFromSteering(scenario, forkContext = null) { + if (!authorState.activeWorkId) return null; + const resolvedForkContext = forkContext || resolveAuthorSteeringForkContext(); + const sourceChapterIndex = Number(resolvedForkContext?.sourceChapterIndex || 0) || 0; + if (!sourceChapterIndex) return null; + const payload = await api(`/v1/author/works/${encodeURIComponent(authorState.activeWorkId)}/branches`, { + method: "POST", + body: JSON.stringify({ + source_chapter_index: sourceChapterIndex, + steering_directive: scenario?.steering_directive || {}, + choice_source: authorState.pendingAuthorBranchSeed?.choiceSourceLabel || null, + account_id: activeAuthorAccountId() || null, + }), + }); + authorState.activeWorkId = payload.work_id; + authorState.activeWorkChapterIndex = Number(payload.fork_after_chapter_index || sourceChapterIndex) || null; + authorState.activeWorkChapterDetail = null; + authorState.pendingAuthorBranchSeed = null; + await refreshAuthorWorks(activeAuthorAccountId()); + return payload; +} + +async function runSteeredSimulation() { + if (!authorState.activeDraftVersionId) { + authorNotice("先选择一个 draft。"); + return; + } + const scenario = buildAuthorSteeringScenario(); + if (!scenario) return; + const releaseBusy = dom.authorRunSteeredSimulation ? setBusy(dom.authorRunSteeredSimulation, "命运线处理中…") : null; + try { + const defaultTriggerChapter = Math.max(1, Number(currentAuthorSimulationReport()?.completed_chapters || 0) + 1); + const forkContext = resolveAuthorSteeringForkContext(defaultTriggerChapter); + if (authorState.activeWorkId) { + if (!forkContext.sourceChapterIndex) { + authorNotice("先在模拟报告里定位到当前章节,再创建新的命运线。", "warning"); + return; + } + setAuthorBranchExecutionState({ + stage: "branch_pending", + workId: authorState.activeWorkId, + branchName: authorState.activeWorkDetail?.branch_name || "主线", + forkAfterChapterIndex: forkContext.sourceChapterIndex, + nextChapterIndex: forkContext.nextBranchStartChapterIndex, + currentChapterCount: Number(authorState.activeWorkDetail?.chapter_count || forkContext.sourceChapterIndex || 0) || 0, + currentRevision: authorState.activeWorkDetail?.current_revision || null, + errorMessage: "", + }); + const branched = await createAuthorWorkBranchFromSteering(scenario, forkContext); + if (branched?.work_id) { + setAuthorBranchExecutionState({ + stage: "generate_pending", + workId: branched.work_id, + rootWorkId: branched.root_work_id || branched.work_id, + branchName: branched.branch_name || "平行宇宙", + forkAfterChapterIndex: Number(branched.fork_after_chapter_index || forkContext.sourceChapterIndex || 0) || 0, + nextChapterIndex: + Number(branched.fork_after_chapter_index || forkContext.sourceChapterIndex || 0) > 0 + ? Number(branched.fork_after_chapter_index || forkContext.sourceChapterIndex || 0) + 1 + : forkContext.nextBranchStartChapterIndex, + currentChapterCount: + Number(branched.chapter_count || branched.fork_after_chapter_index || forkContext.sourceChapterIndex || 0) || 0, + currentRevision: branched.current_revision || null, + errorMessage: "", + }); + const generated = await generateAuthorWork("next"); + if (!generated) { + const fallbackChapterCount = + Number(authorState.activeWorkDetail?.chapter_count || branched.chapter_count || forkContext.sourceChapterIndex || 0) || 0; + const gateMessage = authorState.authorWorkQualityGateFailure?.message || ""; + setAuthorBranchExecutionState({ + stage: "generate_failed", + workId: branched.work_id, + rootWorkId: branched.root_work_id || branched.work_id, + branchName: branched.branch_name || "平行宇宙", + forkAfterChapterIndex: Number(branched.fork_after_chapter_index || forkContext.sourceChapterIndex || 0) || 0, + nextChapterIndex: + Number(branched.fork_after_chapter_index || forkContext.sourceChapterIndex || 0) > 0 + ? Number(branched.fork_after_chapter_index || forkContext.sourceChapterIndex || 0) + 1 + : forkContext.nextBranchStartChapterIndex, + currentChapterCount: fallbackChapterCount, + currentRevision: authorState.activeWorkDetail?.current_revision || branched.current_revision || null, + errorMessage: gateMessage || "生成链路没有返回新的章节结果,请检查上方错误提示。", + }); + authorNotice("新命运线已创建,但下一章没有成功生成。页面内状态卡已保留失败原因。", "warning"); + return; + } + setAuthorBranchExecutionState({ + stage: "generate_succeeded", + workId: generated.work_id || branched.work_id, + rootWorkId: generated.root_work_id || branched.root_work_id || branched.work_id, + branchName: generated.branch_name || branched.branch_name || "平行宇宙", + forkAfterChapterIndex: + Number(generated.fork_after_chapter_index || branched.fork_after_chapter_index || forkContext.sourceChapterIndex || 0) || 0, + nextChapterIndex: Number(generated.chapter_count || forkContext.nextBranchStartChapterIndex || 0) || 0, + currentChapterCount: Number(generated.chapter_count || 0) || 0, + currentRevision: generated.current_revision || null, + errorMessage: "", + }); + WorkspaceLayoutRuntime.setAuthorWorkspace("draft", { silent: true }); + ShellStatusRuntime.syncProductMode(); + authorNotice( + `已从第 ${branched.fork_after_chapter_index || forkContext.sourceChapterIndex} 章后创建${branched.branch_name || "平行宇宙"},并生成第 ${generated.chapter_count || forkContext.nextBranchStartChapterIndex} 章。`, + "success" + ); + return; + } + } + await simulateDraftVersion(authorState.activeDraftVersionId, { + interactiveScenarios: [scenario], + accountId: activeAuthorAccountId(), + }); + authorNotice("已带着引导重跑 Simulation。", "success"); + } catch (error) { + const defaultTriggerChapter = Math.max(1, Number(currentAuthorSimulationReport()?.completed_chapters || 0) + 1); + const forkContext = resolveAuthorSteeringForkContext(defaultTriggerChapter); + const previousState = authorState.authorBranchExecutionState || null; + if (authorState.activeWorkId && forkContext.sourceChapterIndex) { + setAuthorBranchExecutionState({ + stage: previousState?.stage === "generate_pending" ? "generate_failed" : "branch_create_failed", + workId: previousState?.workId || authorState.activeWorkId, + rootWorkId: previousState?.rootWorkId || authorState.activeWorkDetail?.root_work_id || authorState.activeWorkId, + branchName: previousState?.branchName || authorState.activeWorkDetail?.branch_name || "平行宇宙", + forkAfterChapterIndex: Number(previousState?.forkAfterChapterIndex || forkContext.sourceChapterIndex || 0) || 0, + nextChapterIndex: + Number(previousState?.nextChapterIndex || 0) || + (Number(previousState?.forkAfterChapterIndex || forkContext.sourceChapterIndex || 0) > 0 + ? Number(previousState?.forkAfterChapterIndex || forkContext.sourceChapterIndex || 0) + 1 + : forkContext.nextBranchStartChapterIndex), + currentChapterCount: + Number(previousState?.currentChapterCount || authorState.activeWorkDetail?.chapter_count || forkContext.sourceChapterIndex || 0) || 0, + currentRevision: authorState.activeWorkDetail?.current_revision || previousState?.currentRevision || null, + errorMessage: summarizeAuthorExecutionError(error), + }); + } + authorNotice(formatAuthorApiErrorMessage(error, "带着引导重跑 Simulation 失败,请稍后再试。"), "error"); + } finally { + releaseBusy?.(); + } +} + +function prefillAuthorSteeringFromChoice(choice, chapterDetail = null) { + const chapterTask = chapterDetail?.chapter_task || chapterDetail?.chapter_task_json || {}; + const choiceText = normalizeAuthorChoiceText(choice); + if (!choiceText) { + authorNotice("当前选项没有可写入的引导文本。", "warning"); + return; + } + focusAuthorPanel("steering"); + if (dom.authorSteeringIntent) { + dom.authorSteeringIntent.value = choiceText; + dom.authorSteeringIntent.focus(); + } + if (dom.authorSteeringType) { + dom.authorSteeringType.value = "mild_steer"; + } + if (dom.authorSteeringMemoryPatch) { + dom.authorSteeringMemoryPatch.value = ""; + } + if (dom.authorSteeringArc && chapterTask.arc_id) { + dom.authorSteeringArc.value = String(chapterTask.arc_id); + } + authorState.pendingAuthorBranchSeed = { + sourceChapterIndex: Number(chapterDetail?.chapter_index || authorState.activeWorkChapterIndex || 0) || 0, + choiceSourceLabel: choiceText, + }; + renderAuthorSteeringComposer(); + authorNotice("已把这个选项预填到剧情引导区。", "info"); +} + +async function submitDraftVersion(worldVersionId) { + authorState.activeDraftDetail = await api(`/v1/author/drafts/${worldVersionId}`); + const report = await api( + `/v1/author/drafts/${worldVersionId}/submit?account_id=${encodeURIComponent(dom.authorAccountId?.value.trim() || "web_author")}`, + { method: "POST" } + ); + authorState.activeDraftVersionId = worldVersionId; + authorState.authorValidationReport = report; + authorState.selectedAuthorRevisionIndex = null; + authorState.authorWorkflowSummary = null; + await refreshAuthorSurface(); + await refreshOpsSurfaceIfVisible(); + focusAuthorPanel("version_history"); + return report; +} + +async function createAuthorCommentThread() { + if (!authorState.activeDraftVersionId) { + authorNotice("先选择一个 draft。"); + return; + } + const body = dom.authorCommentBody?.value.trim() || ""; + if (!body) { + authorNotice("先写评论内容。"); + return; + } + const actorId = activeAuthorActorId(); + const created = await api(`/v1/author/drafts/${authorState.activeDraftVersionId}/comments`, { + method: "POST", + headers: authorCollaborationHeaders({ actorId, actorRole: activeAuthorActorRole(actorId) }), + body: JSON.stringify({ + revision_id: getActiveRevisionHistory().slice(-1)[0]?.revision_id || null, + anchor_type: dom.authorCommentAnchorType?.value || "draft", + anchor_key: dom.authorCommentAnchorKey?.value.trim() || authorState.activeDraftVersionId, + severity: dom.authorCommentSeverity?.value || "normal", + assignee_id: dom.authorCommentAssignee?.value.trim() || null, + actor_id: actorId, + actor_role: activeAuthorActorRole(actorId), + body, + }), + }); + authorState.selectedAuthorThreadId = created.thread?.thread_id || authorState.selectedAuthorThreadId; + if (dom.authorCommentBody) dom.authorCommentBody.value = ""; + await refreshAuthorSurface(); + focusAuthorPanel("collaboration"); +} + +async function requestAuthorApproval() { + if (!authorState.activeDraftVersionId) { + authorNotice("先选择一个 draft。"); + return; + } + const reviewerId = dom.authorApprovalReviewer?.value.trim() || activeAuthorReviewerId(); + const actorId = activeAuthorActorId(); + await api(`/v1/author/drafts/${authorState.activeDraftVersionId}/approval/request`, { + method: "POST", + headers: authorCollaborationHeaders({ actorId, actorRole: activeAuthorActorRole(actorId) }), + body: JSON.stringify({ + revision_id: getActiveRevisionHistory().slice(-1)[0]?.revision_id || null, + reviewer_id: reviewerId, + reason: dom.authorApprovalReason?.value.trim() || "请求内部审批。", + actor_id: actorId, + actor_role: activeAuthorActorRole(actorId), + }), + }); + if (dom.authorInboxReviewerId && reviewerId) { + dom.authorInboxReviewerId.value = reviewerId; + } + await refreshAuthorSurface(); + focusAuthorPanel("collaboration"); +} + +async function decideAuthorApproval(status) { + if (!authorState.activeDraftVersionId) { + authorNotice("先选择一个 draft。"); + return; + } + const reviewerId = dom.authorApprovalReviewer?.value.trim() || activeAuthorReviewerId(); + await api(`/v1/author/drafts/${authorState.activeDraftVersionId}/approval/decision`, { + method: "POST", + headers: authorCollaborationHeaders({ actorId: reviewerId, actorRole: "reviewer" }), + body: JSON.stringify({ + revision_id: getActiveRevisionHistory().slice(-1)[0]?.revision_id || null, + reviewer_id: reviewerId, + status, + reason: dom.authorApprovalReason?.value.trim() || (status === "approved" ? "批准送审。" : "需要修改。"), + }), + }); + if (dom.authorInboxReviewerId && reviewerId) { + dom.authorInboxReviewerId.value = reviewerId; + } + await refreshAuthorSurface(); + focusAuthorPanel("collaboration"); +} + +async function runAuthorWorkflowAction(actionId) { + const draftId = authorState.authorWorkflowSummary?.world_version_id || authorState.activeDraftVersionId; + if (actionId === "create_from_brief") { + focusAuthorPanel("brief"); + const brief = buildAuthorBriefPayload(); + if (brief.world_title && brief.core_premise) { + await createDraftFromBrief(); + } + return; + } + if (actionId === "copy_current_world") { + await createDraftFromCurrentWorld(); + return; + } + if (actionId === "bootstrap_quick_brief_enrich" && draftId) { + await api(`/v1/author/drafts/${encodeURIComponent(draftId)}/longform-bootstrap`, { + method: "POST", + body: JSON.stringify({ + account_id: activeAuthorAccountId() || null, + mode: "quick_brief_enrich", + target_band: "100", + }), + }); + await refreshAuthorSurface(); + focusAuthorPanel("longform"); + return; + } + if (actionId === "bootstrap_structured_longform" && draftId) { + await api(`/v1/author/drafts/${encodeURIComponent(draftId)}/longform-bootstrap`, { + method: "POST", + body: JSON.stringify({ + account_id: activeAuthorAccountId() || null, + mode: "structured_longform", + target_band: authorState.authorWorkflowSummary?.longform_readiness?.band || "250", + }), + }); + await refreshAuthorSurface(); + focusAuthorPanel("longform"); + return; + } + if (actionId === "validate_draft" && draftId) { + await validateDraftVersion(draftId); + return; + } + if (actionId === "simulate_draft" && draftId) { + await simulateDraftVersion(draftId); + return; + } + if (actionId === "submit_draft" && draftId) { + await submitDraftVersion(draftId); + return; + } + if (actionId === "focus_validation") { + focusAuthorPanel("validation"); + return; + } + if (actionId === "focus_simulation") { + focusAuthorPanel("simulation"); + return; + } + if (actionId === "focus_diff" || actionId === "focus_revision") { + focusAuthorPanel("diff"); + return; + } + if (actionId === "focus_version_history") { + focusAuthorPanel("version_history"); + return; + } + if (actionId === "focus_draft_detail") { + focusAuthorPanel("draft_detail"); + return; + } + if (actionId === "focus_longform") { + focusAuthorPanel("longform"); + return; + } +} + +function populateAuthorBriefForm(force = false) { + const payload = authorState.authorBriefTemplate; + if (!payload) return; + const defaults = payload.defaults || {}; + const presets = payload.genre_presets || []; + if (!dom.authorGenrePreset) return; + if (!dom.authorGenrePreset.options.length) { + dom.authorGenrePreset.innerHTML = presets + .map((preset) => ``) + .join(""); + } + if (force || !String(dom.authorGenrePreset.value || "").trim()) { + dom.authorGenrePreset.value = defaults.genre_preset || presets[0]?.id || "urban_mystery"; + } + setAuthorBriefFieldValue(dom.authorWorldTitle, defaults.world_title, { force }); + setAuthorBriefFieldValue(dom.authorLeadName, defaults.lead_name, { force }); + setAuthorBriefFieldValue(dom.authorCounterpartName, defaults.counterpart_name, { force }); + setAuthorBriefFieldValue(dom.authorSupportingName, defaults.supporting_name, { force }); + setAuthorBriefFieldValue(dom.authorLifeTheme, defaults.life_theme, { force }); + setAuthorBriefFieldValue(dom.authorCorePremise, defaults.core_premise, { force }); + setAuthorBriefFieldValue(dom.authorLocations, defaults.locations, { force }); +} + +function setAuthorBriefFieldValue(node, value, options = {}) { + if (!node) return; + const nextValue = String(value || ""); + if (options.force || !String(node.value || "").trim()) { + node.value = nextValue; + } +} + +function applyAuthorPresetDefaults() { + const payload = authorState.authorBriefTemplate; + if (!payload) return; + const selected = dom.authorGenrePreset?.value; + const defaults = payload.preset_defaults?.[selected]; + if (!defaults) return; + // Genre presets should help fill blanks, not wipe the author's in-progress brief. + setAuthorBriefFieldValue(dom.authorWorldTitle, defaults.world_title); + setAuthorBriefFieldValue(dom.authorLeadName, defaults.lead_name); + setAuthorBriefFieldValue(dom.authorCounterpartName, defaults.counterpart_name); + setAuthorBriefFieldValue(dom.authorSupportingName, defaults.supporting_name); + setAuthorBriefFieldValue(dom.authorLifeTheme, defaults.life_theme); + setAuthorBriefFieldValue(dom.authorCorePremise, defaults.core_premise); + setAuthorBriefFieldValue(dom.authorLocations, defaults.locations); +} + +function buildAuthorBriefPayload() { + return { + genre_preset: dom.authorGenrePreset?.value || "urban_mystery", + world_title: dom.authorWorldTitle?.value.trim() || "", + lead_name: dom.authorLeadName?.value.trim() || "", + counterpart_name: dom.authorCounterpartName?.value.trim() || "", + supporting_name: dom.authorSupportingName?.value.trim() || "", + life_theme: dom.authorLifeTheme?.value.trim() || "", + core_premise: dom.authorCorePremise?.value.trim() || "", + locations: dom.authorLocations?.value || "", + author_id: dom.authorAccountId?.value.trim() || "web_author", + account_id: dom.authorAccountId?.value.trim() || "web_author", + }; +} + +function getActiveDraftCharacters() { + return getActiveDraftWorldpack()?.characters || []; +} + +function getActiveDraftScenes() { + return getActiveDraftWorldpack()?.scene_blueprints || []; +} + +function getActiveSeriesPlan() { + return getActiveDraftWorldpack()?.series_plan || null; +} + +function getActiveVolumePlans() { + return getActiveDraftWorldpack()?.volume_plans || []; +} + +function getActiveArcPlans() { + return getActiveDraftWorldpack()?.arc_plans || []; +} + +function applyDraftWorldpackMutation(mutator) { + const activeWorldpack = getActiveDraftWorldpack(); + if (!activeWorldpack || !authorState.activeDraftDetail) return false; + const nextWorldpack = structuredClone(activeWorldpack); + mutator(nextWorldpack); + authorState.activeDraftDetail = { + ...authorState.activeDraftDetail, + worldpack: nextWorldpack, + worldpack_json: nextWorldpack, + }; + return true; +} + +function resequenceArcOrders(worldpack, volumeId) { + const sameVolume = (worldpack.arc_plans || []).filter((item) => item.volume_id === volumeId); + sameVolume.forEach((arc, index) => { + arc.order = index + 1; + }); +} + +function reorderArcWithinVolume(volumeId, draggedArcId, targetArcId) { + if (!volumeId || !draggedArcId || !targetArcId || draggedArcId === targetArcId) return; + const updated = applyDraftWorldpackMutation((worldpack) => { + const sameVolume = (worldpack.arc_plans || []).filter((item) => item.volume_id === volumeId); + const volumeOrder = new Map((worldpack.volume_plans || []).map((item, index) => [String(item.volume_id || ""), Number(item.order || index + 1)])); + const fromIndex = sameVolume.findIndex((item) => item.arc_id === draggedArcId); + const toIndex = sameVolume.findIndex((item) => item.arc_id === targetArcId); + if (fromIndex < 0 || toIndex < 0) return; + const [moved] = sameVolume.splice(fromIndex, 1); + sameVolume.splice(toIndex, 0, moved); + sameVolume.forEach((arc, index) => { + arc.order = index + 1; + }); + const replacedById = new Map(sameVolume.map((item) => [String(item.arc_id || ""), item])); + worldpack.arc_plans = (worldpack.arc_plans || []) + .map((item) => replacedById.get(String(item.arc_id || "")) || item) + .sort((left, right) => { + if (String(left.volume_id || "") !== String(right.volume_id || "")) { + return Number(volumeOrder.get(String(left.volume_id || "")) || 0) - Number(volumeOrder.get(String(right.volume_id || "")) || 0); + } + return Number(left.order || 0) - Number(right.order || 0); + }); + }); + if (!updated) return; + authorState.selectedAuthorVolumeId = volumeId; + authorState.selectedAuthorArcId = targetArcId; + authorState.selectedAuthorTaskId = null; + renderLongformWorkbench(); +} + +function reorderTaskWithinArc(arcId, draggedTaskId, targetTaskId) { + if (!arcId || !draggedTaskId || !targetTaskId || draggedTaskId === targetTaskId) return; + const updated = applyDraftWorldpackMutation((worldpack) => { + const arc = (worldpack.arc_plans || []).find((item) => item.arc_id === arcId); + if (!arc) return; + const tasks = Array.isArray(arc.chapter_tasks) ? [...arc.chapter_tasks] : []; + const fromIndex = tasks.findIndex((item) => item.chapter_task_id === draggedTaskId); + const toIndex = tasks.findIndex((item) => item.chapter_task_id === targetTaskId); + if (fromIndex < 0 || toIndex < 0) return; + const [moved] = tasks.splice(fromIndex, 1); + tasks.splice(toIndex, 0, moved); + arc.chapter_tasks = tasks; + }); + if (!updated) return; + authorState.selectedAuthorArcId = arcId; + authorState.selectedAuthorTaskId = targetTaskId; + renderLongformWorkbench(); +} + +function moveTaskAcrossArcs(sourceArcId, targetArcId, draggedTaskId, targetTaskId = "") { + if (!sourceArcId || !targetArcId || !draggedTaskId) return; + const updated = applyDraftWorldpackMutation((worldpack) => { + const arcPlans = worldpack.arc_plans || []; + const sourceArc = arcPlans.find((item) => item.arc_id === sourceArcId); + const targetArc = arcPlans.find((item) => item.arc_id === targetArcId); + if (!sourceArc || !targetArc) return; + const sourceTasks = Array.isArray(sourceArc.chapter_tasks) ? [...sourceArc.chapter_tasks] : []; + const targetTasks = sourceArcId === targetArcId + ? sourceTasks + : (Array.isArray(targetArc.chapter_tasks) ? [...targetArc.chapter_tasks] : []); + const fromIndex = sourceTasks.findIndex((item) => item.chapter_task_id === draggedTaskId); + if (fromIndex < 0) return; + const [moved] = sourceTasks.splice(fromIndex, 1); + if (sourceArcId === targetArcId) { + const toIndex = targetTasks.findIndex((item) => item.chapter_task_id === targetTaskId); + if (toIndex < 0) return; + targetTasks.splice(toIndex, 0, moved); + sourceArc.chapter_tasks = targetTasks; + sourceArc.target_chapters = targetTasks.length; + return; + } + const toIndex = targetTaskId ? targetTasks.findIndex((item) => item.chapter_task_id === targetTaskId) : -1; + if (toIndex >= 0) { + targetTasks.splice(toIndex, 0, moved); + } else { + targetTasks.push(moved); + } + sourceArc.chapter_tasks = sourceTasks; + targetArc.chapter_tasks = targetTasks; + sourceArc.target_chapters = sourceTasks.length; + targetArc.target_chapters = targetTasks.length; + }); + if (!updated) return; + authorState.selectedAuthorArcId = targetArcId; + authorState.selectedAuthorTaskId = draggedTaskId; + if (dom.authorTaskBulkIssues) dom.authorTaskBulkIssues.value = ""; + if (dom.authorTaskBulkNotes) dom.authorTaskBulkNotes.value = ""; + renderLongformWorkbench(); +} + +function selectedCharacterIndex() { + return Math.max(0, Number(dom.authorCharacterSelect?.value || 0)); +} + +function selectedSceneIndex() { + return Math.max(0, Number(dom.authorSceneSelect?.value || 0)); +} + +function appendAuthorCardGrid(node, cards) { + clearNode(node); + if (!cards.length) return; + const grid = document.createElement("div"); + grid.className = "author-workspace-summary-grid"; + cards.forEach((card) => grid.appendChild(card)); + node.appendChild(grid); +} + +function formatAuthorActionHints(items) { + const normalized = (items || []) + .map((item) => { + if (typeof item === "string") return item.trim(); + if (!item || typeof item !== "object") return ""; + const issueCode = String(item.issue_code || "").trim(); + const owningModule = String(item.owning_module || "").trim(); + const fixHint = String(item.fix_hint || "").trim(); + if (issueCode && fixHint) return `${issueCode}:${fixHint}`; + if (issueCode && owningModule) return `${issueCode}:${owningModule}`; + if (issueCode) return issueCode; + if (owningModule) return owningModule; + return ""; + }) + .filter(Boolean); + return normalized.join(" / ") || "-"; +} + +function renderAuthorDraftSectionNav() { + if (!dom.authorDraftSectionNav) return; + clearNode(dom.authorDraftSectionNav); + AUTHOR_DRAFT_SECTION_CONFIG.forEach((section) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `segment${currentAuthorDraftSection() === section.key ? " is-active" : ""}`; + button.textContent = section.label; + button.title = section.description; + button.addEventListener("click", () => { + setAuthorDraftSection(section.key, { silent: false }); + if (shellState.authorWorkspace === "draft") { + RouteSyncRuntime.syncShellRoute(); + } + }); + dom.authorDraftSectionNav.appendChild(button); + }); +} + +function renderAuthorDraftSectionSummary() { + if (!dom.authorDraftSectionSummary) return; + if (!authorState.activeDraftVersionId || !authorState.activeDraftDetail) { + clearNode(dom.authorDraftSectionSummary, "选择一个 Draft 后,这里会显示当前子工作区的焦点、阻塞和下一步。"); + return; + } + const worldpack = getActiveDraftWorldpack() || {}; + const section = currentAuthorDraftSection(); + const workflow = authorState.authorWorkflowSummary || {}; + const latestDiff = getLatestDiffSummary(); + const taskLinks = getChapterTaskSimulationLinking().task_links || []; + const continuity = getContinuityDiffWorkbench(); + const promises = getPromiseLedgerWorkbench(); + const selectedTaskLink = + taskLinks.find((item) => item.chapter_task_id === authorState.selectedAuthorTaskId) || + taskLinks.find((item) => item.arc_id === authorState.selectedAuthorArcId) || + taskLinks[0] || + null; + const selectedPromise = + (getPromiseStateWorkbench().editable_promises || []).find((item) => item.promise_id === authorState.selectedAuthorPromiseId) || + (promises.open_promises || [])[0] || + null; + const cardsBySection = { + assets: [ + createAuthorSummaryCard({ + title: "当前资产对象", + score: `${getActiveDraftCharacters().length} 角色 / ${getActiveDraftScenes().length} 场景`, + body: + `角色 ${(getActiveDraftCharacters()[selectedCharacterIndex()]?.display_name || getActiveDraftCharacters()[0]?.display_name || "-")}\n` + + `场景 ${(getActiveDraftScenes()[selectedSceneIndex()]?.scene_id || getActiveDraftScenes()[0]?.scene_id || "-")}\n` + + `最近改动 ${latestDiff.summary_text || "-"}\n` + + `下一步 先修角色/场景,再去 Review 看 diff 证据。`, + actionLabel: "查看送审证据", + onAction: () => focusAuthorPanel("diff"), + primary: true, + }), + createAuthorSummaryCard({ + title: "草稿状态", + score: authorStageLabel(workflow.stage), + body: + `校验 ${workflow.validation_summary?.status || "-"}\n` + + `模拟 ${workflow.simulation_summary?.latest_decision || "-"}\n` + + `freshness ${workflow.simulation_freshness?.status || "-"}\n` + + `blocker ${(workflow.blockers || [])[0]?.message || "当前没有 blocker"}`, + }), + ], + longform: [ + createAuthorSummaryCard({ + title: "当前规划对象", + score: getActiveSeriesPlan() ? "已建立" : "未建立", + body: + `series ${getActiveSeriesPlan()?.title || worldpack.title || "-"}\n` + + `volume ${getActiveVolumePlans().find((item) => item.volume_id === authorState.selectedAuthorVolumeId)?.title || getActiveVolumePlans()[0]?.title || "-"}\n` + + `arc ${getActiveArcPlans().find((item) => item.arc_id === authorState.selectedAuthorArcId)?.title || getActiveArcPlans()[0]?.title || "-"}\n` + + `下一步 先确认卷/弧线,再把 promise mapping 对齐到当前计划。`, + actionLabel: getActiveSeriesPlan() ? "查看承诺映射" : "生成长篇规划", + onAction: () => focusAuthorPanel("longform"), + primary: true, + }), + createAuthorSummaryCard({ + title: "计划桥接", + score: `${(getSeriesVolumeArcPromiseMapping().mappings || []).length} mappings`, + body: + `mapped promises ${(getSeriesVolumeArcPromiseMapping().mappings || []).length}\n` + + `task links ${taskLinks.length}\n` + + `selected task ${selectedTaskLink?.chapter_task_id || "-"}\n` + + `下一步 把规划对象连回 simulation 证据。`, + }), + ], + repair: [ + createAuthorSummaryCard({ + title: "当前修稿对象", + score: selectedTaskLink?.status || selectedPromise?.current_state || "待选择", + body: + `task ${selectedTaskLink?.chapter_task_id || "-"}\n` + + `promise ${selectedPromise?.promise_id || "-"}\n` + + `continuity ${(continuity.top_changed_chapters || [])[0]?.chapter_index ? `#${(continuity.top_changed_chapters || [])[0].chapter_index}` : "-"}\n` + + `下一步 先标注 override / promise state,再回 Compare 看证据。`, + actionLabel: "打开连续性对照", + onAction: () => focusAuthorPanel("continuity"), + primary: true, + }), + createAuthorSummaryCard({ + title: "修稿桥", + score: `${(promises.open_promises || []).length} open promises`, + body: + `linked chapters ${selectedTaskLink?.linked_chapters?.length || 0}\n` + + `compare chapters ${selectedTaskLink?.compare_chapters?.length || 0}\n` + + `drift ${(continuity.promise_risks || []).length}\n` + + `下一步 把当前问题从 simulation 拉回 Draft。`, + }), + ], + style: [ + createAuthorSummaryCard({ + title: "风格窗口", + score: `${(worldpack.narrative_style_pack?.tonal_lexicon || []).length} tokens`, + body: + `tone ${(worldpack.narrative_style_pack?.tonal_lexicon || []).slice(0, 3).join(" / ") || "-"}\n` + + `hook ${(worldpack.narrative_style_pack?.hook_templates || []).slice(0, 2).join(" / ") || "-"}\n` + + `turns ${worldpack.dialogue_realism_policy?.min_turns || 2}-${worldpack.dialogue_realism_policy?.max_turns || 3}\n` + + `下一步 先调风格与节奏,再决定是否改 capability JSON。`, + actionLabel: "查看最近修改", + onAction: () => focusAuthorPanel("diff"), + primary: true, + }), + createAuthorSummaryCard({ + title: "能力资产", + score: `${Object.keys(worldpack.voice_profiles || {}).length} voices`, + body: + `voice ${Object.keys(worldpack.voice_profiles || {}).length}\n` + + `action ${Object.keys(worldpack.emotion_action_policies || {}).length}\n` + + `sensory ${Object.keys(worldpack.sensory_grounding_policies || {}).length}\n` + + `scene ${Object.keys(worldpack.scene_realization_contracts || {}).length}`, + }), + ], + }; + appendAuthorCardGrid(dom.authorDraftSectionSummary, cardsBySection[section] || []); +} + +function renderAuthorSettingsSummary() { + if (!dom.authorSettingsSummary) return; + const preferences = authorState.authorNotificationPreferences?.preferences || []; + const collaboration = authorState.authorCollaborationSummary || {}; + const authIdentity = authorState.authorAuthSession?.identity || null; + const notificationTargets = preferences + .map((item) => item.delivery_target || item.async_sink_name || "") + .filter(Boolean) + .slice(0, 3) + .join(" / ") || "未配置外部投递"; + const cards = [ + createAuthorSummaryCard({ + title: "登录态", + score: authIdentity?.actor_role || "未登录", + body: + `actor ${authIdentity?.actor_id || "-"}\n` + + `account ${authIdentity?.account_id || "-"}\n` + + `expires ${authorState.authorAuthSession?.expiresAt || "-"}\n` + + `下一步 ${authIdentity ? "确认通知和协作" : "先建立作者身份"}`, + warning: !authIdentity + ? "当前没有登录态,通知与协作身份不会稳定同步。" + : isAuthorSessionExpiringSoon(authorState.authorAuthSession?.expiresAt) + ? "当前 token 即将过期,建议刷新会话。" + : "", + actionLabel: authIdentity ? "刷新会话" : "登录", + onAction: () => focusAuthorPanel("auth_settings"), + primary: true, + }), + createAuthorSummaryCard({ + title: "通知态", + score: `${preferences.length} rules`, + body: + `in-app ${preferences.filter((item) => item.in_app_enabled).length}\n` + + `async ${preferences.filter((item) => item.async_mirror_enabled).length}\n` + + `targets ${notificationTargets}\n` + + `下一步 ${preferences.length ? "检查是否覆盖当前 Draft" : "先建一条通知规则"}`, + warning: !preferences.length + ? "当前没有自定义通知规则,重要更新可能只能手动刷新看到。" + : !preferences.some((item) => item.delivery_target || item.async_sink_name) + ? "当前没有外部投递目标,异步提醒可能收不到。" + : "", + actionLabel: "打开通知设置", + onAction: () => focusAuthorPanel("notification_settings"), + }), + createAuthorSummaryCard({ + title: "协作态", + score: collaboration.recommended_next_action || "-", + body: + `open ${collaboration.open_thread_count ?? 0} · blocking ${collaboration.blocking_thread_count ?? 0}\n` + + `approval ${(collaboration.approval_summary || {}).latest_status || "-"}\n` + + `unread ${(collaboration.notification_summary || {}).unread_count ?? 0}\n` + + `下一步 ${collaboration.recommended_next_action || "打开协作区"}`, + warning: + Number(collaboration.blocking_thread_count || 0) > 0 + ? `当前有 ${collaboration.blocking_thread_count} 个 blocker 线程待处理。` + : Number((collaboration.notification_summary || {}).unread_count || 0) > 0 + ? `当前有 ${(collaboration.notification_summary || {}).unread_count || 0} 条未读协作通知。` + : "", + actionLabel: "打开协作区", + onAction: () => focusAuthorPanel("collaboration"), + }), + ]; + appendAuthorCardGrid(dom.authorSettingsSummary, cards); +} + +function renderAuthorSimulateSummary() { + if (!dom.authorSimulateSummary) return; + const simulationDrilldown = getSimulationDrilldown(); + const simulationReport = currentAuthorSimulationReport(); + const repairLoop = currentAuthorRepairLoopState(); + const creativeCockpit = currentAuthorCreativeCockpit() || {}; + const steeringTimeline = creativeCockpit.steering_timeline || {}; + const latestCheckpoint = [...(steeringTimeline.entries || [])].reverse().find((item) => item.entry_type === "checkpoint") || null; + const latestReplan = [...(steeringTimeline.entries || [])].reverse().find((item) => item.entry_type === "replan") || null; + const hottestRelationship = (creativeCockpit.relationship_hotspots?.items || [])[0] || null; + const workflow = authorState.authorWorkflowSummary || {}; + const firstIssueTarget = (simulationDrilldown.issue_focus_queue || [])[0]?.chapter_targets?.[0] || null; + const firstWeakChapter = (simulationDrilldown.weakest_chapters || [])[0] || null; + if (!authorState.activeDraftVersionId) { + clearNode(dom.authorSimulateSummary, "先选择并生成一个草稿,再看问题诊断的修稿驾驶舱。"); + return; + } + const cards = [ + ...(repairLoop?.available ? [createAuthorSummaryCard({ + title: `${repairLoop.issueLabel || repairLoop.issueCode || "Repair Loop"} 结果`, + score: repairLoop.severityTrend || "-", + body: + `asset ${(repairLoop.assetLabel || repairLoop.assetType || "-")}${repairLoop.targetLabel ? ` -> ${repairLoop.targetLabel}` : ""}\n` + + `issue count ${repairLoop.baselineIssueCount ?? "-"} -> ${repairLoop.currentIssueCount ?? "-"}\n` + + `worst decision ${repairLoop.baselineWorstDecision || "-"} -> ${repairLoop.currentWorstDecision || "-"}\n` + + `remaining ${(repairLoop.remainingChapters || []).map((item) => `${item.chapter_index}.${item.chapter_title || "-"}`).join(" / ") || "-"}\n` + + `ready ${repairLoop.readyForValidation ? "yes" : "no"} · 回 ${repairLoop.validationPanelLabel || repairLoop.validationPanel || "-"}`, + actionLabel: repairLoop.readyForValidation ? `去${repairLoop.validationPanelLabel || repairLoop.validationPanel || "复核"}` : "先看复核面板", + onAction: () => { + setAuthorRepairLoopHint(repairLoop); + openAuthorRepairLoopValidationPanel(); + }, + primary: true, + })] : []), + createAuthorSummaryCard({ + title: "最新判断", + score: workflow.simulation_summary?.latest_decision || "-", + body: + `freshness ${workflow.simulation_freshness?.status || "-"}\n` + + `completed ${simulationReport?.completed_chapters || 0}\n` + + `pass ${formatPercent(simulationReport?.evaluation_summary?.pass_rate)}\n` + + `latest steer ${latestCheckpoint ? `${latestCheckpoint.title} @ ${latestCheckpoint.chapter_index}` : "-"}\n` + + `下一步 ${firstIssueTarget ? "先跳到问题章节" : "先看重点问题章节"}`, + actionLabel: firstIssueTarget ? `跳到第 ${firstIssueTarget.chapter_index} 章` : (latestCheckpoint ? `跳到第 ${latestCheckpoint.chapter_index} 章` : "查看重点问题章节"), + onAction: () => { + if (firstIssueTarget) { + jumpToAuthorChapter(firstIssueTarget.chapter_index, "simulation"); + return; + } + if (latestCheckpoint?.chapter_index) { + jumpToAuthorChapter(latestCheckpoint.chapter_index, "simulation"); + return; + } + if (firstWeakChapter) { + jumpToAuthorChapter(firstWeakChapter.chapter_index, "simulation"); + } + }, + primary: true, + }), + createAuthorSummaryCard({ + title: "Steering 回响", + score: `${steeringTimeline.checkpoint_count ?? 0} 次`, + body: + `latest replan ${latestReplan?.summary || latestReplan?.title || "-"}\n` + + `memory pending ${steeringTimeline.memory_patch_summary?.pending_count ?? 0} · adopted ${steeringTimeline.memory_patch_summary?.adopted_count ?? 0}\n` + + `impacted ${(latestCheckpoint?.impacted_characters || []).join(" / ") || "-"}\n` + + `next 先确认这次引导有没有把关系压到你想要的方向。`, + actionLabel: latestCheckpoint?.chapter_index ? `查看第 ${latestCheckpoint.chapter_index} 章` : "打开创作驾驶舱", + onAction: () => { + if (latestCheckpoint?.chapter_index) { + jumpToAuthorChapter(latestCheckpoint.chapter_index, "simulation"); + return; + } + focusAuthorCreativeCockpit(); + }, + }), + createAuthorSummaryCard({ + title: "最短回路", + score: hottestRelationship ? `${hottestRelationship.source_label} -> ${hottestRelationship.target_label}` : (firstWeakChapter?.chapter_index ? `#${firstWeakChapter.chapter_index}` : "待选择"), + body: + `weak chapter ${firstWeakChapter?.chapter_title || "-"}\n` + + `relationship ${hottestRelationship ? `${hottestRelationship.dominant_metric_label} ${Number(hottestRelationship.dominant_metric_value || 0).toFixed(2)} · debt ${hottestRelationship.debt_count}` : "-"}\n` + + `review ${formatAuthorActionHints((simulationDrilldown.next_actions || []).slice(0, 2))}\n` + + `next 修完后再回章节对照和送审区留证据。`, + actionLabel: "打开创作驾驶舱", + onAction: focusAuthorCreativeCockpit, + }), + ]; + appendAuthorCardGrid(dom.authorSimulateSummary, cards); +} + +function createAuthorCockpitPanel(title, score = "", description = "") { + const panel = document.createElement("article"); + panel.className = "list-card author-cockpit-card"; + const head = document.createElement("div"); + head.className = "list-card-head"; + const heading = document.createElement("h3"); + heading.textContent = title; + const scoreNode = document.createElement("span"); + scoreNode.className = "list-card-score"; + scoreNode.textContent = score; + head.append(heading, scoreNode); + panel.appendChild(head); + if (description) { + const body = document.createElement("p"); + body.className = "list-card-body"; + body.textContent = description; + panel.appendChild(body); + } + return panel; +} + +let authorRelationshipNetworkMarkerCounter = 0; + +function buildRelationshipNetworkSvg(network) { + const width = 560; + const height = 260; + const nodeRadius = 26; + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", `0 0 ${width} ${height}`); + svg.setAttribute("class", "author-relationship-network"); + const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); + const markerId = `author-network-arrow-${++authorRelationshipNetworkMarkerCounter}`; + const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker"); + marker.setAttribute("id", markerId); + marker.setAttribute("markerWidth", "10"); + marker.setAttribute("markerHeight", "10"); + marker.setAttribute("refX", "8"); + marker.setAttribute("refY", "3"); + marker.setAttribute("orient", "auto"); + const arrowPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); + arrowPath.setAttribute("d", "M0,0 L0,6 L9,3 z"); + arrowPath.setAttribute("fill", "rgba(45, 87, 211, 0.62)"); + marker.appendChild(arrowPath); + defs.appendChild(marker); + svg.appendChild(defs); + + const nodes = Array.isArray(network.nodes) ? network.nodes : []; + const edges = Array.isArray(network.edges) ? network.edges.slice(0, 8) : []; + const centerX = width / 2; + const centerY = height / 2; + const ringRadius = Math.max(92, Math.min(128, 76 + nodes.length * 8)); + const nodePositions = {}; + const directedEdgeKeys = new Set( + edges + .map((edge) => `${String(edge.source || "")}::${String(edge.target || "")}`) + .filter((key) => key !== "::") + ); + + nodes.forEach((node, index) => { + const angle = ((Math.PI * 2) / Math.max(1, nodes.length)) * index - Math.PI / 2; + nodePositions[node.character_id] = { + x: centerX + Math.cos(angle) * ringRadius, + y: centerY + Math.sin(angle) * ringRadius, + }; + }); + + edges.forEach((edge) => { + const source = nodePositions[edge.source]; + const target = nodePositions[edge.target]; + if (!source || !target) return; + const deltaX = target.x - source.x; + const deltaY = target.y - source.y; + const distance = Math.hypot(deltaX, deltaY) || 1; + const unitX = deltaX / distance; + const unitY = deltaY / distance; + const normalX = -unitY; + const normalY = unitX; + const reciprocalKey = `${String(edge.target || "")}::${String(edge.source || "")}`; + const hasReciprocal = directedEdgeKeys.has(reciprocalKey) && reciprocalKey !== `${String(edge.source || "")}::${String(edge.target || "")}`; + const curveDirection = hasReciprocal ? (String(edge.source || "") < String(edge.target || "") ? 1 : -1) : 0; + const curveOffset = hasReciprocal ? Math.min(26, 12 + distance * 0.08) : 0; + const startX = source.x + unitX * nodeRadius; + const startY = source.y + unitY * nodeRadius; + const endX = target.x - unitX * nodeRadius; + const endY = target.y - unitY * nodeRadius; + const controlX = (startX + endX) / 2 + normalX * curveOffset * curveDirection; + const controlY = (startY + endY) / 2 + normalY * curveOffset * curveDirection; + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", `M ${startX} ${startY} Q ${controlX} ${controlY} ${endX} ${endY}`); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", edge.conflict >= edge.intensity ? "rgba(165, 68, 47, 0.7)" : "rgba(45, 87, 211, 0.65)"); + path.setAttribute("stroke-width", String(1.5 + Math.min(3.5, Number(edge.dominant_metric_value || 0) * 4))); + path.setAttribute("marker-end", `url(#${markerId})`); + path.setAttribute("stroke-linecap", "round"); + svg.appendChild(path); + + }); + + nodes.forEach((node) => { + const position = nodePositions[node.character_id]; + if (!position) return; + const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circle.setAttribute("cx", String(position.x)); + circle.setAttribute("cy", String(position.y)); + circle.setAttribute("r", String(nodeRadius)); + circle.setAttribute("class", "author-relationship-network-node"); + svg.appendChild(circle); + + const title = document.createElementNS("http://www.w3.org/2000/svg", "text"); + title.setAttribute("x", String(position.x)); + title.setAttribute("y", String(position.y - 2)); + title.setAttribute("text-anchor", "middle"); + title.setAttribute("class", "author-relationship-network-title"); + title.textContent = node.label; + svg.appendChild(title); + + const subtitle = document.createElementNS("http://www.w3.org/2000/svg", "text"); + subtitle.setAttribute("x", String(position.x)); + subtitle.setAttribute("y", String(position.y + 13)); + subtitle.setAttribute("text-anchor", "middle"); + subtitle.setAttribute("class", "author-relationship-network-subtitle"); + subtitle.textContent = node.role || node.character_id; + svg.appendChild(subtitle); + }); + + return svg; +} + +function relationshipStrengthLabel(value) { + const normalized = Number(value || 0); + if (normalized >= 0.6) return "强"; + if (normalized >= 0.35) return "中"; + return "轻"; +} + +function buildHeatmapIssueAggregation(chapters) { + const counts = new Map(); + (chapters || []).forEach((chapter) => { + const uniqueCodes = [...new Set((chapter.issue_codes || []).map((item) => String(item || "").trim()).filter(Boolean))]; + uniqueCodes.forEach((issueCode) => { + counts.set(issueCode, (counts.get(issueCode) || 0) + 1); + }); + }); + return [...counts.entries()] + .map(([issueCode, count]) => ({ issueCode, count })) + .sort((left, right) => right.count - left.count || left.issueCode.localeCompare(right.issueCode)); +} + +function authorSceneFunctionShortLabel(sceneFunction) { + const normalized = String(sceneFunction || "").trim(); + return ( + { + false_peace: "假平静", + truth_trial: "真相试压", + trust_test: "信任试探", + temptation: "诱惑推进", + reversal: "关系反转", + discovery: "真相揭口", + confession_window: "告白窗口", + karma_ripening: "代价追上", + setup: "开场搭建", + unknown: "未标注场景", + }[normalized] || normalized || "stable" + ); +} + +function authorHeatmapDecisionLabel(decision) { + return ( + { + pass: "通过", + rewrite: "重写", + block: "阻断", + stable: "稳定", + watch: "关注", + critical: "高危", + }[String(decision || "").trim()] || String(decision || "-").trim() || "-" + ); +} + +function appendHeatmapIssueBadgeRow(targetNode, chapter, issueAggregation) { + const issueCodes = [...new Set((chapter.issue_codes || []).map((item) => String(item || "").trim()).filter(Boolean))]; + if (!issueCodes.length) return; + const aggregationMap = new Map((issueAggregation || []).map((item) => [item.issueCode, Number(item.count || 0)])); + const badgeRow = document.createElement("div"); + badgeRow.className = "author-heatmap-cell-badges"; + issueCodes.slice(0, 2).forEach((issueCode) => { + const badge = document.createElement("span"); + badge.className = "author-heatmap-badge"; + const repeatCount = aggregationMap.get(issueCode) || 0; + badge.textContent = repeatCount > 1 ? `${issueCode} ×${repeatCount}` : issueCode; + badge.title = repeatCount > 1 ? `${issueCode} 在 ${(issueAggregation || []).find((item) => item.issueCode === issueCode)?.count || repeatCount} 个章节重复出现` : issueCode; + badgeRow.appendChild(badge); + }); + if (issueCodes.length > 2) { + const overflowBadge = document.createElement("span"); + overflowBadge.className = "author-heatmap-badge is-overflow"; + overflowBadge.textContent = `+${issueCodes.length - 2}`; + overflowBadge.title = issueCodes.slice(2).join(" / "); + badgeRow.appendChild(overflowBadge); + } + targetNode.appendChild(badgeRow); +} + +function renderAuthorCreativeCockpit() { + if (!dom.authorCreativeCockpit) return; + const cockpit = currentAuthorCreativeCockpit(); + clearNode(dom.authorCreativeCockpit); + if (!cockpit?.available) { + clearNode(dom.authorCreativeCockpit, "运行 simulation 后,这里会显示人物关系网、Steering 时间线、章节热图和长线结构快照。"); + return; + } + + const relationshipNetwork = cockpit.relationship_network || {}; + const hotspots = cockpit.relationship_hotspots || {}; + const steeringTimeline = cockpit.steering_timeline || {}; + const chapterHeatmap = cockpit.chapter_heatmap || {}; + const issuePriorityGroups = chapterHeatmap.issue_priority_groups || []; + const storyStructure = cockpit.story_structure_snapshot || {}; + + const summaryGrid = document.createElement("div"); + summaryGrid.className = "author-workspace-summary-grid"; + summaryGrid.appendChild( + createAuthorSummaryCard({ + title: "关系图谱", + score: `${Math.min(5, (hotspots.items || []).length)} 条热点`, + body: + `当前最强关系 ${((hotspots.items || [])[0]?.dominant_metric_label || "-")} ${Number((hotspots.items || [])[0]?.dominant_metric_value || 0).toFixed(2)}\n` + + `最重债务 ${Math.max(...((hotspots.items || []).map((item) => Number(item.debt_count || 0))), 0)}\n` + + `下一步 先处理榜单第一条关系,再决定这次引导到底改到了谁。`, + actionLabel: "回作品稿边栏", + onAction: () => focusAuthorPanel("draft_detail"), + }) + ); + summaryGrid.appendChild( + createAuthorSummaryCard({ + title: "Steering 时间线", + score: `${steeringTimeline.checkpoint_count ?? 0} 次`, + body: + `replans ${steeringTimeline.replan_event_count ?? 0}\n` + + `memory pending ${steeringTimeline.memory_patch_summary?.pending_count ?? 0}\n` + + `memory adopted ${steeringTimeline.memory_patch_summary?.adopted_count ?? 0}\n` + + `next 看这轮引导有没有变成真正被吸收的记忆。`, + }) + ); + summaryGrid.appendChild( + createAuthorSummaryCard({ + title: "章节热图", + score: `${(chapterHeatmap.chapters || []).length} 章`, + body: + `阻断 ${(chapterHeatmap.chapters || []).filter((item) => item.decision === "block").length}\n` + + `重写 ${(chapterHeatmap.chapters || []).filter((item) => item.decision === "rewrite").length}\n` + + `通过 ${(chapterHeatmap.chapters || []).filter((item) => item.decision === "pass").length}\n` + + `issue groups ${issuePriorityGroups.map((item) => item.issue_code).join(" / ") || "-"}\n` + + `next 先去最红的章节,再回 Draft 修素材。`, + }) + ); + dom.authorCreativeCockpit.appendChild(summaryGrid); + + const impactPanel = createAuthorCockpitPanel( + "关系影响榜单", + `${Math.min(5, (hotspots.items || []).length)} 条`, + "先看最值得处理的几组关系:是谁影响谁、当前主导指标是什么、为什么值得现在就处理。" + ); + if ((hotspots.items || []).length) { + (hotspots.items || []).slice(0, 5).forEach((edge, index) => { + const row = document.createElement("div"); + row.className = "author-cockpit-row"; + const copy = document.createElement("p"); + copy.className = "author-cockpit-row-copy"; + copy.textContent = + `${index + 1}. ${edge.source_label} -> ${edge.target_label}\n` + + `主导关系 ${edge.dominant_metric_label} ${Number(edge.dominant_metric_value || 0).toFixed(2)} · ${relationshipStrengthLabel(edge.dominant_metric_value)}\n` + + `冲突 ${Number(edge.conflict || 0).toFixed(2)} · 债务 ${edge.debt_count}\n` + + `线索 ${(edge.notes_preview || []).join(" / ") || "暂无额外注记"}`; + const actions = document.createElement("div"); + actions.className = "composer-actions author-card-actions"; + const sourceButton = document.createElement("button"); + sourceButton.className = "ghost-action"; + sourceButton.textContent = `打开 ${edge.source_label}`; + sourceButton.addEventListener("click", () => openAuthorCharacterAsset(edge.source)); + const targetButton = document.createElement("button"); + targetButton.className = "ghost-action"; + targetButton.textContent = `打开 ${edge.target_label}`; + targetButton.addEventListener("click", () => openAuthorCharacterAsset(edge.target)); + actions.append(sourceButton, targetButton); + row.append(copy, actions); + impactPanel.appendChild(row); + }); + } + dom.authorCreativeCockpit.appendChild(impactPanel); + + const networkPanel = createAuthorCockpitPanel( + "关系结构示意", + `${Math.min(8, (relationshipNetwork.edges || []).length)} 条边`, + "这只是辅助示意图。真正要处理哪条关系,请优先看上面的关系影响榜单。" + ); + if (relationshipNetwork.available) { + networkPanel.appendChild(buildRelationshipNetworkSvg(relationshipNetwork)); + const note = document.createElement("p"); + note.className = "author-cockpit-legend-item"; + note.textContent = "图上只保留最关键的几条关系边,边越粗代表当前强度越高。"; + networkPanel.appendChild(note); + } + dom.authorCreativeCockpit.appendChild(networkPanel); + + const hotspotsPanel = createAuthorCockpitPanel("关系热点详情", `${(hotspots.items || []).length} 条`, "需要更细的关系细节时,再看这里的完整说明和角色卡入口。"); + if ((hotspots.items || []).length) { + (hotspots.items || []).forEach((edge) => { + const row = document.createElement("div"); + row.className = "author-cockpit-row"; + const copy = document.createElement("p"); + copy.className = "author-cockpit-row-copy"; + copy.textContent = `${edge.source_label} -> ${edge.target_label}\n${edge.dominant_metric_label} ${Number(edge.dominant_metric_value || 0).toFixed(2)} · conflict ${Number(edge.conflict || 0).toFixed(2)} · debt ${edge.debt_count}\n${(edge.notes_preview || []).join(" / ") || "暂无额外注记"}`; + const actions = document.createElement("div"); + actions.className = "composer-actions author-card-actions"; + const sourceButton = document.createElement("button"); + sourceButton.className = "ghost-action"; + sourceButton.textContent = "源角色卡"; + sourceButton.addEventListener("click", () => openAuthorCharacterAsset(edge.source)); + const targetButton = document.createElement("button"); + targetButton.className = "ghost-action"; + targetButton.textContent = "目标角色卡"; + targetButton.addEventListener("click", () => openAuthorCharacterAsset(edge.target)); + actions.append(sourceButton, targetButton); + row.append(copy, actions); + hotspotsPanel.appendChild(row); + }); + } + dom.authorCreativeCockpit.appendChild(hotspotsPanel); + + const steeringPanel = createAuthorCockpitPanel("Steering 时间线", `${steeringTimeline.checkpoint_count ?? 0} checkpoints`, "每一条 steering 都应该能回到它真正改动的角色、场景或任务资产。"); + if ((steeringTimeline.entries || []).length) { + (steeringTimeline.entries || []).forEach((entry) => { + const row = document.createElement("div"); + row.className = "author-cockpit-row"; + const copy = document.createElement("p"); + copy.className = "author-cockpit-row-copy"; + copy.textContent = `第 ${entry.chapter_index || "-"} 章 · ${entry.title || entry.entry_type}\n${entry.summary || "-"}\n角色 ${(entry.impacted_characters || []).join(" / ") || "-"} · ${entry.status || "-"}\nscene ${entry.scene_id || entry.scene_function || "-"} · task ${entry.chapter_task_id || entry.arc_id || "-"}`; + const actions = document.createElement("div"); + actions.className = "composer-actions author-card-actions"; + if ((entry.impacted_character_ids || [])[0]) { + const characterButton = document.createElement("button"); + characterButton.className = "ghost-action"; + characterButton.textContent = "角色卡"; + characterButton.addEventListener("click", () => openAuthorCharacterAsset(entry.impacted_character_ids[0])); + actions.appendChild(characterButton); + } + if (entry.scene_id || entry.scene_function) { + const sceneButton = document.createElement("button"); + sceneButton.className = "ghost-action"; + sceneButton.textContent = "场景蓝图"; + sceneButton.addEventListener("click", () => openAuthorSceneAsset({ sceneId: entry.scene_id, sceneFunction: entry.scene_function })); + actions.appendChild(sceneButton); + } + if (entry.chapter_task_id || entry.arc_id || entry.volume_id) { + const taskButton = document.createElement("button"); + taskButton.className = "ghost-action"; + taskButton.textContent = "章节任务"; + taskButton.addEventListener("click", () => openAuthorTaskAsset({ volumeId: entry.volume_id, arcId: entry.arc_id, taskId: entry.chapter_task_id })); + actions.appendChild(taskButton); + } + const simulationButton = document.createElement("button"); + simulationButton.className = "ghost-action"; + simulationButton.textContent = "看 simulation"; + simulationButton.addEventListener("click", () => jumpToAuthorChapter(entry.chapter_index, "simulation")); + actions.appendChild(simulationButton); + row.append(copy, actions); + steeringPanel.appendChild(row); + }); + } + dom.authorCreativeCockpit.appendChild(steeringPanel); + + const heatmapPanel = createAuthorCockpitPanel( + "章节热图", + `${(chapterHeatmap.chapters || []).length} 章`, + "颜色越重,说明当前 simulation 越需要你优先处理这一章。点击任意章节会直接跳到对应模拟条目。" + ); + const heatmapIssueAggregation = buildHeatmapIssueAggregation(chapterHeatmap.chapters || []); + if (issuePriorityGroups.length) { + const issuePriorityBlock = document.createElement("div"); + issuePriorityBlock.className = "author-issue-priority-grid"; + issuePriorityGroups.forEach((group) => { + const row = document.createElement("div"); + row.className = "author-cockpit-row"; + const primary = group.primary_asset || {}; + const copy = document.createElement("p"); + copy.className = "author-cockpit-row-copy"; + copy.textContent = + `${group.issue_code} · ${group.label || "-"}\n` + + `涉及章节 ${group.chapter_count || 0} · 主修 ${primary.label || "-"}${primary.target_label ? ` -> ${primary.target_label}` : ""}\n` + + `复核面板 ${group.primary_validation_panel_label || primary.validation_panel_label || "-"}\n` + + `建议 ${group.fix_hint || "-"}\n` + + `热点 ${(group.chapters || []).map((chapter) => `${chapter.chapter_index}. ${chapter.chapter_title || "-"}`).join(" / ") || "-"}`; + const actions = document.createElement("div"); + actions.className = "composer-actions author-card-actions"; + (group.asset_priorities || []).forEach((priority) => { + const button = document.createElement("button"); + button.className = + primary.asset_type === priority.asset_type && priority.available + ? "primary-action" + : "ghost-action"; + button.textContent = `${priority.priority}. ${priority.label}${priority.available ? "" : " (缺锚点)"}`; + button.disabled = !priority.available; + button.title = priority.reason || ""; + button.addEventListener("click", () => openAuthorPriorityAsset(priority, group)); + actions.appendChild(button); + }); + if (primary.validation_panel) { + const validationButton = document.createElement("button"); + validationButton.className = "ghost-action"; + validationButton.textContent = `复核 ${group.primary_validation_panel_label || primary.validation_panel}`; + validationButton.addEventListener("click", () => { + setAuthorRepairLoopHint(buildAuthorRepairLoopHint(primary, group)); + openAuthorRepairLoopValidationPanel(); + }); + actions.appendChild(validationButton); + } + const inspectButton = document.createElement("button"); + inspectButton.className = "ghost-action"; + inspectButton.textContent = "看首个热点章节"; + inspectButton.addEventListener("click", () => { + if ((group.chapters || [])[0]?.chapter_index) { + jumpToAuthorChapter(group.chapters[0].chapter_index, "simulation"); + } + }); + actions.appendChild(inspectButton); + row.append(copy, actions); + issuePriorityBlock.appendChild(row); + }); + heatmapPanel.appendChild(issuePriorityBlock); + } + if (heatmapIssueAggregation.length) { + const heatmapBadgeSummary = document.createElement("div"); + heatmapBadgeSummary.className = "author-heatmap-summary-badges"; + heatmapIssueAggregation.slice(0, 4).forEach((item) => { + const badge = document.createElement("span"); + badge.className = "author-heatmap-summary-badge"; + badge.textContent = `${item.issueCode} ×${item.count}`; + heatmapBadgeSummary.appendChild(badge); + }); + heatmapPanel.appendChild(heatmapBadgeSummary); + } + const heatmapGrid = document.createElement("div"); + heatmapGrid.className = "author-heatmap-grid"; + (chapterHeatmap.chapters || []).forEach((chapter) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `author-heatmap-cell is-${chapter.severity || "stable"}`; + const title = document.createElement("strong"); + title.textContent = String(chapter.chapter_index || "-"); + const decision = document.createElement("span"); + decision.textContent = authorHeatmapDecisionLabel(chapter.decision || "-"); + const caption = document.createElement("small"); + caption.textContent = authorSceneFunctionShortLabel(chapter.scene_function || chapter.dominant_issue || "stable"); + button.append(title, decision); + appendHeatmapIssueBadgeRow(button, chapter, heatmapIssueAggregation); + button.appendChild(caption); + button.addEventListener("click", () => { + jumpToAuthorChapter(chapter.chapter_index, "simulation"); + }); + heatmapGrid.appendChild(button); + }); + heatmapPanel.appendChild(heatmapGrid); + const hotspotList = document.createElement("div"); + hotspotList.className = "author-cockpit-legend"; + (chapterHeatmap.chapters || []) + .slice() + .sort((left, right) => { + const order = { critical: 0, watch: 1, stable: 2 }; + return ( + (order[left.severity || "stable"] - order[right.severity || "stable"]) || + Number(left.overall_score || 0) - Number(right.overall_score || 0) + ); + }) + .slice(0, 4) + .forEach((chapter) => { + const row = document.createElement("div"); + row.className = "author-cockpit-row"; + const copy = document.createElement("p"); + copy.className = "author-cockpit-row-copy"; + copy.textContent = `第 ${chapter.chapter_index} 章 · ${chapter.chapter_title || "-"}\n${chapter.dominant_issue || authorSceneFunctionShortLabel(chapter.scene_function) || "stable"} · score ${Number(chapter.overall_score || 0).toFixed(3)}\nscene ${chapter.scene_id || authorSceneFunctionShortLabel(chapter.scene_function) || "-"} · task ${chapter.chapter_task_id || chapter.arc_id || "-"}`; + const actions = document.createElement("div"); + actions.className = "composer-actions author-card-actions"; + if (chapter.scene_id || chapter.scene_function) { + const sceneButton = document.createElement("button"); + sceneButton.className = "ghost-action"; + sceneButton.textContent = "场景蓝图"; + sceneButton.addEventListener("click", () => openAuthorSceneAsset({ sceneId: chapter.scene_id, sceneFunction: chapter.scene_function })); + actions.appendChild(sceneButton); + } + if (chapter.chapter_task_id || chapter.arc_id || chapter.volume_id) { + const taskButton = document.createElement("button"); + taskButton.className = "ghost-action"; + taskButton.textContent = "章节任务"; + taskButton.addEventListener("click", () => openAuthorTaskAsset({ volumeId: chapter.volume_id, arcId: chapter.arc_id, taskId: chapter.chapter_task_id })); + actions.appendChild(taskButton); + } + const simulationButton = document.createElement("button"); + simulationButton.className = "ghost-action"; + simulationButton.textContent = "看 simulation"; + simulationButton.addEventListener("click", () => jumpToAuthorChapter(chapter.chapter_index, "simulation")); + actions.appendChild(simulationButton); + row.append(copy, actions); + hotspotList.appendChild(row); + }); + heatmapPanel.appendChild(hotspotList); + dom.authorCreativeCockpit.appendChild(heatmapPanel); + + const structurePanel = createAuthorCockpitPanel( + "长线结构快照", + `${storyStructure.volume_snapshot_count ?? 0} volume snapshots`, + `series snapshots ${storyStructure.series_snapshot_count ?? 0}\nending ${(storyStructure.series_ending_checkpoint?.status || "-")} · ready ${storyStructure.series_ending_checkpoint?.terminal_ready ? "yes" : "no"}` + ); + (storyStructure.volumes || []).forEach((volume) => { + const row = document.createElement("div"); + row.className = "author-cockpit-row"; + const copy = document.createElement("p"); + copy.className = "author-cockpit-row-copy"; + copy.textContent = `${volume.title || volume.volume_id}\nchapters ${volume.simulated_chapter_count ?? 0}/${volume.target_chapters ?? 0} · snapshots ${volume.memory_snapshot_count ?? 0} · ${volume.status || "-"}`; + const actions = document.createElement("div"); + actions.className = "composer-actions author-card-actions"; + if (volume.first_arc_id || volume.volume_id) { + const taskButton = document.createElement("button"); + taskButton.className = "ghost-action"; + taskButton.textContent = "打开本卷任务"; + taskButton.addEventListener("click", () => openAuthorTaskAsset({ volumeId: volume.volume_id, arcId: volume.first_arc_id })); + actions.appendChild(taskButton); + } + row.append(copy, actions); + structurePanel.appendChild(row); + }); + (storyStructure.arcs || []).forEach((arc) => { + const row = document.createElement("div"); + row.className = "author-cockpit-row"; + const copy = document.createElement("p"); + copy.className = "author-cockpit-row-copy"; + copy.textContent = `${arc.title || arc.arc_id}\nchapters ${arc.simulated_chapter_count ?? 0}/${arc.target_chapters ?? 0} · ${arc.status || "-"}`; + const actions = document.createElement("div"); + actions.className = "composer-actions author-card-actions"; + const taskButton = document.createElement("button"); + taskButton.className = "ghost-action"; + taskButton.textContent = "打开章节任务"; + taskButton.addEventListener("click", () => openAuthorTaskAsset({ volumeId: arc.volume_id, arcId: arc.arc_id, taskId: arc.first_task_id })); + actions.appendChild(taskButton); + row.append(copy, actions); + structurePanel.appendChild(row); + }); + dom.authorCreativeCockpit.appendChild(structurePanel); +} + +function renderAuthorReviewSummary() { + if (!dom.authorReviewSummary) return; + if (!authorState.activeDraftVersionId) { + clearNode(dom.authorReviewSummary, "选择一个草稿后,这里会显示当前版本的证据顺序与送审状态。"); + return; + } + const latestDiff = getLatestDiffSummary(); + const revisions = getActiveRevisionHistory(); + const compareWorkbench = getContinuityDiffWorkbench(); + const workflow = authorState.authorWorkflowSummary || {}; + const firstChanged = (compareWorkbench.top_changed_chapters || [])[0] || null; + const cards = [ + createAuthorSummaryCard({ + title: "送审准备度", + score: authorStageLabel(workflow.stage), + body: + `recommended ${workflow.recommended_action || "-"}\n` + + `latest diff ${latestDiff.summary_text || "-"}\n` + + `version history ${revisions.length}\n` + + `下一步 ${workflow.stage === "ready_to_submit" ? "可以送审" : "先补齐证据"}`, + actionLabel: workflow.stage === "ready_to_submit" ? "送审" : "看版本轨迹", + onAction: () => { + if (workflow.stage === "ready_to_submit" && authorState.activeDraftVersionId) { + submitDraftVersion(authorState.activeDraftVersionId).catch((error) => { + authorNotice(formatAuthorApiErrorMessage(error, "送审失败,请稍后再试。"), "error"); + }); + return; + } + focusAuthorPanel("version_history"); + }, + primary: true, + }), + createAuthorSummaryCard({ + title: "章节对照证据", + score: firstChanged?.chapter_index ? `#${firstChanged.chapter_index}` : "待选择", + body: + `changed ${(compareWorkbench.top_changed_chapters || []).length}\n` + + `current ${(firstChanged?.after_title || firstChanged?.chapter_title || "-")}\n` + + `issue ${(firstChanged?.issue_codes || []).join(" / ") || "-"}\n` + + `下一步 先确认最重要的修改前后章节。`, + actionLabel: "打开章节对照", + onAction: () => focusAuthorPanel("compare"), + }), + createAuthorSummaryCard({ + title: "版本证据栈", + score: `${revisions.length} revisions`, + body: + `latest source ${revisions[0]?.change_context?.source || "-"}\n` + + `latest label ${revisions[0]?.change_context?.label || "-"}\n` + + `改写预览 ${authorState.authorCurrentRewritePatchExport ? "已准备" : "待生成"}\n` + + `下一步 按最近修改 -> 章节对照 -> 版本轨迹的顺序检查。`, + }), + ]; + appendAuthorCardGrid(dom.authorReviewSummary, cards); +} + +function renderAuthorDraftDetail() { + clearNode(dom.authorDraftDetail); + const worldpack = getActiveDraftWorldpack(); + if (!worldpack || !authorState.activeDraftVersionId) { + clearNode(dom.authorDraftDetail, "选择一个草稿后,这里会显示当前定位、健康状态与诊断摘要。"); + return; + } + const validation = authorState.activeDraftDetail?.validation_report || authorState.authorValidationReport || {}; + const simulation = authorState.activeDraftDetail?.simulation_report || authorState.authorSimulationReport || {}; + const simulationDrilldown = getSimulationDrilldown(); + const latestDiff = getLatestDiffSummary(); + const stylePack = worldpack.narrative_style_pack || {}; + const detail = document.createElement("article"); + detail.className = "list-card author-detail-card"; + const diagnosis = simulation.cross_pack_summary?.worlds?.find((item) => item.world_id === authorState.activeDraftDetail?.world_id) || null; + const weakestSummary = (diagnosis?.issue_summary?.weakest_dimensions || []).map((item) => `${item.name} ${Number(item.value || 0).toFixed(3)}`).join(" / ") || "暂无明显弱项"; + detail.innerHTML = ` +
+

${worldpack.title || authorState.activeDraftDetail?.world_id || authorState.activeDraftVersionId}

+ ${authorState.activeDraftDetail?.status || "草稿中"} +
+
+
+ 项目定位 + ${authorState.activeDraftDetail?.world_id || "-"} +

${(worldpack.manifest?.genres || []).join(" / ") || "未标注题材"} · ${worldpack.manifest?.risk_rating || "未标注风险"}
${authorState.activeDraftVersionId}

+
+
+ 当前健康 + 校验 ${validation.ok ? "通过" : "待处理"} +

问题 ${(validation.errors || []).length || 0} · 提醒 ${(validation.warnings || []).length || 0}
诊断 ${simulation.latest_decision || "-"} · 通过率 ${formatPercent(simulation.evaluation_summary?.pass_rate)}

+
+
+ 运行态 + 章节 ${simulationDrilldown.completed_chapters ?? simulation.completed_chapters ?? 0} +

完成度 ${simulationDrilldown.completion_ratio !== undefined ? Number(simulationDrilldown.completion_ratio).toFixed(3) : "-"}
当前停止原因 ${simulationDrilldown.stop_reason || "-"}

+
+
+ 叙事风格 + ${(stylePack.tonal_lexicon || []).slice(0, 2).join(" / ") || "待补充"} +

钩子 ${(stylePack.hook_templates || [])[0] || "-"}
对白轮次 ${worldpack.dialogue_realism_policy?.min_turns || 2}-${worldpack.dialogue_realism_policy?.max_turns || 3}

+
+
+ 当前诊断 + ${diagnosis?.issue_summary?.dominant_issue || "暂无主导问题"} +

薄弱项 ${weakestSummary}
建议处理 ${diagnosis?.issue_summary?.recommended_target || "-"}
最近修改 ${latestDiff.summary_text || "-"}

+
+
+ `; + dom.authorDraftDetail.appendChild(detail); + const workSummary = document.createElement("article"); + workSummary.className = "list-card"; + const activeWork = authorState.activeWorkDetail; + const workDiag = authorState.authorWorkDiagnostics || {}; + workSummary.innerHTML = ` +
+

作品稿

+ ${activeWork ? `${activeWork.chapter_count || 0}/${activeWork.target_chapter_count || 0}` : "未初始化"} +
+

${ + activeWork + ? `状态 ${activeWork.status || "-"}\n章节 ${activeWork.chapter_count || 0}/${activeWork.target_chapter_count || 0}\n诊断 ${workDiag.latest_decision || "-"} · pass ${formatPercent((workDiag.evaluation_summary || {}).pass_rate)}` + : "当前 draft 还没有作品稿。先在创作台里初始化作品稿,再逐章生成和编辑。" + }

+ `; + dom.authorDraftDetail.appendChild(workSummary); + const readiness = authorState.activeDraftDetail?.longform_readiness || {}; + const quickBriefRunway = authorState.activeDraftDetail?.quick_brief_runway_summary || {}; + const promiseRunway = authorState.activeDraftDetail?.promise_runway_summary || {}; + const capabilitySummary = document.createElement("article"); + capabilitySummary.className = "list-card"; + capabilitySummary.innerHTML = ` +
+

长线能力

+ ${authorState.activeDraftDetail?.claim_safe_band ? `${authorState.activeDraftDetail.claim_safe_band}章` : "未达安全承诺"} +
+

入口 ${authorState.activeDraftDetail?.entry_mode || "-"}\n目标 ${authorState.activeDraftDetail?.requested_target_chapters || "-"} 章 / ${authorState.activeDraftDetail?.requested_target_band || "-"} band\n当前支持 ${authorState.activeDraftDetail?.supported_target_band || "-"} · readiness ${readiness.status || "-"}\n结构 ${authorState.activeDraftDetail?.longform_structure_counts?.character_count || 0} 角色 / ${authorState.activeDraftDetail?.longform_structure_counts?.scene_blueprint_count || 0} 场景 / ${authorState.activeDraftDetail?.longform_structure_counts?.location_count || 0} 地点 / ${authorState.activeDraftDetail?.longform_structure_counts?.scene_family_count || 0} scene family / ${authorState.activeDraftDetail?.longform_structure_counts?.distinct_role_pair_count || 0} role pairs\nquick brief runway ${quickBriefRunway.status || "-"} · 缺口 ${(quickBriefRunway.gaps || []).join(" / ") || "-"}\npromise runway ${promiseRunway.runway_status || "-"} · open ${promiseRunway.open_count ?? 0} · overdue ${promiseRunway.overdue_count ?? 0}\nblockers ${(readiness.blockers || []).map((item) => item.message).join(" / ") || "-"}

+ `; + dom.authorDraftDetail.appendChild(capabilitySummary); + const actions = document.createElement("div"); + actions.className = "composer-actions author-card-actions"; + const openReviewButton = document.createElement("button"); + openReviewButton.className = "ghost-action"; + openReviewButton.textContent = "打开管理员审核视图"; + openReviewButton.addEventListener("click", () => openAuthorAdminView("review")); + actions.appendChild(openReviewButton); + const openAccountButton = document.createElement("button"); + openAccountButton.className = "ghost-action"; + openAccountButton.textContent = "打开管理员账户视图"; + openAccountButton.addEventListener("click", () => openAuthorAdminView("account")); + actions.appendChild(openAccountButton); + detail.appendChild(actions); +} + +function renderAuthorWorkStudio() { + clearNode(dom.authorDraftSectionSummary); + const activeDraft = authorState.activeDraftDetail; + if (!activeDraft || !authorState.activeDraftVersionId) { + clearNode(dom.authorDraftSectionSummary, "先选择一个 draft,再初始化作品稿。"); + return; + } + const work = authorState.activeWorkDetail; + const chapters = work?.chapters || []; + const chapterPayload = authorState.activeWorkChapterDetail || null; + const chapterDetail = chapterPayload?.chapter || null; + const draftSection = AUTHOR_DRAFT_SECTION_CONFIG.find((item) => item.key === currentAuthorDraftSection()) || AUTHOR_DRAFT_SECTION_CONFIG[0]; + const diagnostics = authorState.authorWorkDiagnostics || work?.diagnostics_summary || work?.diagnostics_summary_json || {}; + const evaluationSummary = diagnostics.evaluation_summary || {}; + const chapterDiagnostics = chapterDetail?.latest_diagnostic_summary || {}; + const chapterTask = chapterDetail?.chapter_task || chapterDetail?.chapter_task_json || {}; + const chapterChoices = chapterDetail?.choices || chapterDetail?.choices_json || []; + const creativeCockpit = activeDraft?.creative_cockpit || activeDraft?.simulation_report?.creative_cockpit || {}; + const relationshipHotspots = creativeCockpit.relationship_hotspots?.items || []; + const currentDraft = activeAuthorWorkDraft(); + const nextAction = authorWorkNextAction(work); + + const shell = document.createElement("section"); + shell.className = "author-editor-shell"; + + const hero = document.createElement("article"); + hero.className = "author-editor-hero"; + const heroHead = document.createElement("div"); + heroHead.className = "author-editor-hero-head"; + const heroTitle = document.createElement("div"); + heroTitle.innerHTML = ` +

作品稿主工作区

+

${work ? work.title || "当前作品稿" : "初始化你的第一部作品稿"}

+

${ + work + ? `当前 Draft ${authorState.activeDraftVersionId || "-"} · 创作配置层 ${draftSection.label} · 下一步 ${nextAction}。` + : `世界设定和长篇规划已经准备好,这里会成为你每天真正写作、修稿和送审的入口。` + }

+ `; + heroHead.appendChild(heroTitle); + if ((work?.branch_family || []).length > 1) { + const branchSwitcher = document.createElement("div"); + branchSwitcher.className = "composer-actions author-card-actions"; + (work.branch_family || []).forEach((branch) => { + const button = document.createElement("button"); + button.className = branch.work_id === work.work_id ? "primary-action" : "ghost-action"; + button.textContent = branch.branch_kind === "mainline" ? "主线" : branch.branch_name || "平行宇宙"; + button.title = branch.branch_origin_label + ? `${branch.branch_origin_label}\n从第 ${branch.fork_after_chapter_index || 0} 章后分叉` + : `从第 ${branch.fork_after_chapter_index || 0} 章后分叉`; + button.addEventListener("click", async () => { + try { + await switchActiveAuthorWork(branch.work_id); + } catch (error) { + authorNotice(formatAuthorApiErrorMessage(error, "切换命运线失败,请稍后再试。"), "error"); + } + }); + branchSwitcher.appendChild(button); + }); + heroTitle.appendChild(branchSwitcher); + } + const heroStatus = document.createElement("div"); + heroStatus.className = "author-editor-hero-status"; + [ + { label: "作品状态", value: work ? authorWorkStatusLabel(work.status) : "未初始化", tone: authorWorkStatusTone(work?.status) }, + { label: "章节进度", value: work ? `${work.chapter_count || 0}/${work.target_chapter_count || 0}` : "0/0", tone: "is-pending" }, + { label: "当前诊断", value: diagnostics.latest_decision || "-", tone: diagnostics.latest_decision === "pass" ? "is-complete" : "is-pending" }, + { label: "当前章节", value: chapterDetail ? `第 ${chapterDetail.chapter_index} 章` : "未选择", tone: chapterDetail ? "is-active" : "is-pending" }, + ].forEach((item) => { + const chip = document.createElement("div"); + chip.className = `author-editor-status-chip ${item.tone || "is-pending"}`; + chip.innerHTML = `${item.label}${item.value}`; + heroStatus.appendChild(chip); + }); + heroHead.appendChild(heroStatus); + hero.appendChild(heroHead); + + const actionBar = document.createElement("div"); + actionBar.className = "author-editor-actions"; + const createButton = document.createElement("button"); + createButton.className = work ? "ghost-action" : "primary-action"; + createButton.textContent = work ? "刷新作品稿" : "初始化作品稿"; + createButton.addEventListener("click", async () => { + try { + await createAuthorWorkFromDraft(); + } catch (error) { + authorNotice(formatAuthorApiErrorMessage(error, "初始化作品稿失败,请稍后再试。"), "error"); + } + }); + actionBar.appendChild(createButton); + if (work) { + [ + ["生成第一章", "first", chapters.length ? "ghost-action" : "ghost-action"], + ["生成下一章", "next", "primary-action"], + ["生成当前弧线", "arc", "ghost-action"], + ].forEach(([label, mode, className]) => { + const button = document.createElement("button"); + button.className = className; + button.textContent = label; + button.addEventListener("click", async () => { + try { + await generateAuthorWork(mode); + } catch (error) { + authorNotice(formatAuthorApiErrorMessage(error, "生成章节失败,请稍后再试。"), "error"); + } + }); + actionBar.appendChild(button); + }); + const diagnosticsButton = document.createElement("button"); + diagnosticsButton.className = "ghost-action"; + diagnosticsButton.textContent = "运行作品诊断"; + diagnosticsButton.addEventListener("click", async () => { + try { + await runAuthorWorkDiagnostics(); + } catch (error) { + authorNotice(formatAuthorApiErrorMessage(error, "运行作品诊断失败,请稍后再试。"), "error"); + } + }); + actionBar.appendChild(diagnosticsButton); + const submitButton = document.createElement("button"); + submitButton.className = work?.status === "review_ready" ? "primary-action" : "ghost-action"; + submitButton.textContent = "送审作品稿"; + submitButton.disabled = work?.status !== "review_ready"; + submitButton.title = work?.status === "review_ready" ? "" : "先把 block_rate 降到 0,再送审作品稿。"; + submitButton.addEventListener("click", async () => { + try { + await submitAuthorWorkForReview(); + } catch (error) { + authorNotice(formatAuthorApiErrorMessage(error, "送审作品稿失败,请稍后再试。"), "error"); + } + }); + actionBar.appendChild(submitButton); + } + hero.appendChild(actionBar); + shell.appendChild(hero); + + if (authorState.authorWorkQualityGateFailure?.quality_gate) { + const gateFailure = authorState.authorWorkQualityGateFailure; + shell.appendChild( + createListCard({ + title: "章节硬约束未通过", + score: gateFailure.action_label || "未入库", + body: formatAuthorQualityGateSummary(gateFailure.quality_gate), + active: true, + }) + ); + } + + if (!work) { + const emptyState = document.createElement("article"); + emptyState.className = "author-editor-empty"; + emptyState.innerHTML = ` +

先初始化作品稿,再开始逐章创作。

+

当前 draft 的世界设定、角色卡和长篇规划都会作为创作配置层保留在下方;正文会在这里成为主工作对象。

+ `; + if (currentDraftAuthorAccountId()) { + const loginHint = document.createElement("p"); + loginHint.textContent = `这份 Draft 属于作者账号 ${currentDraftAuthorAccountId()}。若初始化失败,请先在“账户协作”里登录这个账号。`; + emptyState.appendChild(loginHint); + } + shell.appendChild(emptyState); + dom.authorDraftSectionSummary.appendChild(shell); + return; + } + + const grid = document.createElement("div"); + grid.className = "author-editor-grid"; + + const rail = document.createElement("aside"); + rail.className = "author-editor-panel author-editor-rail"; + const railHead = document.createElement("div"); + railHead.className = "author-editor-panel-head"; + railHead.innerHTML = ` +
+

章节导航

+

作品元信息与章节列表

+
+ `; + rail.appendChild(railHead); + const railMeta = document.createElement("div"); + railMeta.className = "author-editor-meta-list"; + [ + ["世界版本", work.world_version_id || "-"], + ["创作配置层", draftSection.label], + ["最新修订", work.latest_revision?.revision_type || "-"], + ["最近更新", formatTimestamp(work.updated_at)], + ].forEach(([label, value]) => { + const row = document.createElement("div"); + row.className = "author-editor-meta-row"; + row.innerHTML = `${label}${value}`; + railMeta.appendChild(row); + }); + rail.appendChild(railMeta); + const chapterList = document.createElement("div"); + chapterList.className = "author-chapter-rail"; + chapters.forEach((item) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `author-chapter-row${Number(item.chapter_index) === Number(authorState.activeWorkChapterIndex) ? " is-active" : ""}`; + button.addEventListener("click", async () => { + try { + await loadAuthorWorkChapter(item.chapter_index); + } catch (error) { + authorNotice(formatAuthorApiErrorMessage(error, "加载章节失败,请稍后再试。"), "error"); + } + }); + const titleBlock = document.createElement("div"); + titleBlock.className = "author-chapter-row-copy"; + const eyebrow = document.createElement("span"); + eyebrow.className = "author-chapter-row-index"; + eyebrow.textContent = `第 ${item.chapter_index} 章`; + const title = document.createElement("strong"); + title.textContent = item.chapter_title || `第 ${item.chapter_index} 章`; + const meta = document.createElement("span"); + meta.className = "author-chapter-row-meta"; + meta.textContent = `${item.status || "-"} · ${item.source_type || "-"}`; + titleBlock.append(eyebrow, title, meta); + button.appendChild(titleBlock); + chapterList.appendChild(button); + }); + if (!chapters.length) { + const empty = document.createElement("p"); + empty.className = "author-editor-empty-copy"; + empty.textContent = "还没有正文章节。先生成第一章。"; + chapterList.appendChild(empty); + } + rail.appendChild(chapterList); + grid.appendChild(rail); + + const editor = document.createElement("section"); + editor.className = "author-editor-panel author-editor-main"; + const editorHead = document.createElement("div"); + editorHead.className = "author-editor-panel-head"; + editorHead.innerHTML = ` +
+

章节正文

+

${chapterDetail ? `第 ${chapterDetail.chapter_index} 章` : "未选择章节"}

+
+ `; + const headerMeta = document.createElement("div"); + headerMeta.className = "author-editor-inline-meta"; + const statusPill = document.createElement("span"); + statusPill.className = "author-editor-pill"; + statusPill.textContent = chapterDetail?.status || "未生成"; + const dirtyPill = document.createElement("span"); + dirtyPill.id = "author-work-dirty-pill"; + dirtyPill.className = "author-editor-pill"; + headerMeta.append(statusPill, dirtyPill); + editorHead.appendChild(headerMeta); + editor.appendChild(editorHead); + + const statusLine = document.createElement("p"); + statusLine.id = "author-work-editor-state"; + statusLine.className = "author-editor-state"; + editor.appendChild(statusLine); + + if (!chapterDetail) { + const empty = document.createElement("div"); + empty.className = "author-editor-empty"; + empty.innerHTML = ` +

还没有可编辑的章节。

+

先生成第一章或下一章,作品正文会在这里展开。

+ `; + editor.appendChild(empty); + } else { + const form = document.createElement("div"); + form.className = "author-editor-form"; + + const titleLabel = document.createElement("label"); + titleLabel.className = "input-label"; + titleLabel.htmlFor = "author-work-chapter-title"; + titleLabel.textContent = "章节标题"; + const titleInput = document.createElement("input"); + titleInput.id = "author-work-chapter-title"; + titleInput.className = "field-input"; + titleInput.type = "text"; + titleInput.value = currentDraft?.chapter_title || ""; + titleInput.placeholder = "例如:第 3 章 · 风把门缝吹开"; + titleInput.addEventListener("input", (event) => { + updateAuthorWorkDraftField("chapter_title", event.target.value); + }); + + const summaryLabel = document.createElement("label"); + summaryLabel.className = "input-label"; + summaryLabel.htmlFor = "author-work-chapter-summary"; + summaryLabel.textContent = "章节摘要"; + const summaryInput = document.createElement("textarea"); + summaryInput.id = "author-work-chapter-summary"; + summaryInput.rows = 4; + summaryInput.placeholder = "记录这一章的摘要、修订意图或你想保留的关键线索。"; + summaryInput.value = currentDraft?.summary || ""; + summaryInput.addEventListener("input", (event) => { + updateAuthorWorkDraftField("summary", event.target.value); + }); + + const bodyLabel = document.createElement("label"); + bodyLabel.className = "input-label"; + bodyLabel.htmlFor = "author-work-chapter-body"; + bodyLabel.textContent = "正文"; + const bodyInput = document.createElement("textarea"); + bodyInput.id = "author-work-chapter-body"; + bodyInput.className = "author-work-body-input"; + bodyInput.rows = 22; + bodyInput.placeholder = "这里是作者真正逐章打磨的正文。"; + bodyInput.value = currentDraft?.body || ""; + bodyInput.addEventListener("input", (event) => { + updateAuthorWorkDraftField("body", event.target.value); + }); + + const editorActions = document.createElement("div"); + editorActions.className = "author-editor-main-actions"; + const saveButton = document.createElement("button"); + saveButton.id = "author-save-work-chapter"; + saveButton.className = "primary-action"; + saveButton.textContent = "保存当前章节"; + saveButton.addEventListener("click", async () => { + try { + await saveAuthorWorkChapter(); + } catch (error) { + authorState.activeWorkSaveState = "idle"; + refreshAuthorWorkEditorChrome(); + authorNotice(formatAuthorApiErrorMessage(error, "保存章节失败,请稍后再试。"), "error"); + } + }); + editorActions.appendChild(saveButton); + form.append(titleLabel, titleInput, summaryLabel, summaryInput, bodyLabel, bodyInput, editorActions); + editor.appendChild(form); + } + grid.appendChild(editor); + + const sidecar = document.createElement("aside"); + sidecar.className = "author-editor-panel author-editor-sidecar"; + const sidecarHead = document.createElement("div"); + sidecarHead.className = "author-editor-panel-head"; + sidecarHead.innerHTML = ` +
+

章节辅助

+

诊断、任务与连续性

+
+ `; + sidecar.appendChild(sidecarHead); + const sidecarSections = [ + { + title: "当前章任务", + body: Object.keys(chapterTask).length + ? [ + chapterTask.task_id ? `任务 ${chapterTask.task_id}` : "", + chapterTask.duty ? `职责 ${chapterTask.duty}` : "", + chapterTask.objective ? `目标 ${chapterTask.objective}` : "", + chapterTask.promise_actions?.length ? `promises ${chapterTask.promise_actions.join(" / ")}` : "", + ].filter(Boolean).join("\n") + : "当前章节还没有任务上下文,生成章节后会在这里显示 chapter task。", + }, + { + title: "当前章诊断", + body: chapterDiagnostics.issue_count || chapterDiagnostics.decision + ? `decision ${chapterDiagnostics.decision || "-"}\nissues ${chapterDiagnostics.issue_count || 0}\nissue codes ${(chapterDiagnostics.issue_codes || []).join(" / ") || "-"}\nnext ${chapterDiagnostics.recommended_action || "-"}` + : "当前章节还没有诊断摘要。运行作品诊断后,这里会显示章节级判断。", + }, + { + title: "作品级诊断", + body: + `latest ${diagnostics.latest_decision || "-"}\n` + + `pass ${formatPercent(evaluationSummary.pass_rate)}\n` + + `rewrite ${formatPercent(evaluationSummary.rewrite_rate)}\n` + + `block ${formatPercent(evaluationSummary.block_rate)}\n` + + `next ${(diagnostics.next_actions || []).join(" / ") || "-"}`, + }, + ]; + sidecarSections.forEach((section) => { + const card = document.createElement("section"); + card.className = "author-editor-sidecard"; + const title = document.createElement("h5"); + title.textContent = section.title; + const body = document.createElement("p"); + body.textContent = section.body; + card.append(title, body); + sidecar.appendChild(card); + }); + + const choiceCard = document.createElement("section"); + choiceCard.className = "author-editor-sidecard"; + const choiceTitle = document.createElement("h5"); + choiceTitle.textContent = "Choices 参考"; + choiceCard.appendChild(choiceTitle); + if (chapterChoices.length) { + const choiceList = document.createElement("div"); + choiceList.className = "author-choice-list"; + chapterChoices.forEach((choice, index) => { + const item = document.createElement("div"); + item.className = "author-choice-item"; + const label = document.createElement("strong"); + label.textContent = `选项 ${index + 1}`; + const copy = document.createElement("p"); + copy.textContent = normalizeAuthorChoiceText(choice) || JSON.stringify(choice); + const actions = document.createElement("div"); + actions.className = "composer-actions author-card-actions"; + const prefillButton = document.createElement("button"); + prefillButton.className = "ghost-action"; + prefillButton.textContent = "用这个选项预填引导"; + prefillButton.addEventListener("click", () => { + prefillAuthorSteeringFromChoice(choice, chapterDetail); + }); + actions.appendChild(prefillButton); + item.append(label, copy, actions); + choiceList.appendChild(item); + }); + choiceCard.appendChild(choiceList); + } else { + const empty = document.createElement("p"); + empty.textContent = "当前章节还没有 choices 参考。先生成章节或运行诊断,再把其中一个选项转成 steering。"; + choiceCard.appendChild(empty); + } + sidecar.appendChild(choiceCard); + + const relationshipCard = document.createElement("section"); + relationshipCard.className = "author-editor-sidecard"; + const relationshipTitle = document.createElement("h5"); + relationshipTitle.textContent = "关系快照"; + relationshipCard.appendChild(relationshipTitle); + const relationshipBody = document.createElement("p"); + relationshipBody.textContent = relationshipHotspots.length + ? relationshipHotspots + .slice(0, 3) + .map( + (edge) => + `${edge.source_label} -> ${edge.target_label}\n${edge.dominant_metric_label} ${Number(edge.dominant_metric_value || 0).toFixed(2)} · debt ${edge.debt_count}` + ) + .join("\n\n") + : "先跑一次 simulation,这里会显示当前最紧绷的三条关系和债务方向。"; + relationshipCard.appendChild(relationshipBody); + const relationshipActions = document.createElement("div"); + relationshipActions.className = "composer-actions author-card-actions"; + const cockpitButton = document.createElement("button"); + cockpitButton.className = "ghost-action"; + cockpitButton.textContent = "打开创作驾驶舱"; + cockpitButton.addEventListener("click", focusAuthorCreativeCockpit); + relationshipActions.appendChild(cockpitButton); + relationshipCard.appendChild(relationshipActions); + sidecar.appendChild(relationshipCard); + grid.appendChild(sidecar); + + shell.appendChild(grid); + const readingPreviewPanel = document.createElement("section"); + readingPreviewPanel.className = "author-editor-panel author-reading-preview-panel"; + const readingPreviewHead = document.createElement("div"); + readingPreviewHead.className = "author-editor-panel-head"; + readingPreviewHead.innerHTML = ` +
+

作品阅读预览

+

用阅读视角查看当前作品稿

+
+ `; + readingPreviewPanel.appendChild(readingPreviewHead); + const readingPreviewCopy = document.createElement("p"); + readingPreviewCopy.className = "panel-copy author-reading-preview-copy"; + readingPreviewCopy.textContent = work && chapterDetail + ? "这里会把当前选中章节按阅读卡片方式展开。左侧切章,右侧编辑;阅读预览只看标题和正文,不把章节摘要混进正文流。" + : "初始化作品稿并至少生成一章后,这里会以阅读卡片方式展示正文。"; + readingPreviewPanel.appendChild(readingPreviewCopy); + if (work && chapterDetail) { + const readingFeed = document.createElement("div"); + readingFeed.className = "story-feed author-reading-preview-feed"; + const card = document.createElement("article"); + card.className = "story-feed-card is-active"; + const header = document.createElement("div"); + header.className = "story-feed-head"; + const heading = document.createElement("h3"); + heading.textContent = chapterDetail.chapter_title || `第 ${chapterDetail.chapter_index} 章`; + const meta = document.createElement("p"); + meta.className = "author-reading-preview-meta"; + meta.textContent = `第 ${chapterDetail.chapter_index} 章 · ${chapterDetail.source_type || "generated"} · ${(chapterDiagnostics.issue_codes || []).join(" / ") || "暂无显式问题"}`; + header.append(heading, meta); + card.appendChild(header); + const body = document.createElement("div"); + body.className = "story-feed-body"; + body.textContent = chapterDetail.body || "这一章还没有正文。"; + card.appendChild(body); + const hintItems = []; + if ((chapterDetail.chapter_task || chapterTask).duty_type) { + hintItems.push(`任务 ${(chapterDetail.chapter_task || chapterTask).duty_type}`); + } + (chapterDetail.choices || chapterChoices || []).slice(0, 2).forEach((choice, index) => { + hintItems.push(`选项${index + 1} ${normalizeAuthorChoiceText(choice)}`.trim()); + }); + if (hintItems.length) { + const hints = document.createElement("div"); + hints.className = "story-feed-hints"; + hintItems.forEach((item) => { + const pill = document.createElement("span"); + pill.textContent = item; + hints.appendChild(pill); + }); + card.appendChild(hints); + } + const actions = document.createElement("div"); + actions.className = "composer-actions author-card-actions"; + const editButton = document.createElement("button"); + editButton.className = "ghost-action"; + editButton.textContent = "回到正文编辑"; + editButton.addEventListener("click", () => { + document.querySelector("#author-work-editor-current-chapter")?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + actions.appendChild(editButton); + card.appendChild(actions); + readingFeed.appendChild(card); + readingPreviewPanel.appendChild(readingFeed); + } else { + const empty = document.createElement("div"); + empty.className = "author-editor-empty"; + empty.innerHTML = ` +

还没有可阅读的章节。

+

先初始化作品稿并生成至少一章,阅读预览会在这里按连续章节的方式展开。

+ `; + readingPreviewPanel.appendChild(empty); + } + shell.appendChild(readingPreviewPanel); + dom.authorDraftSectionSummary.appendChild(shell); + refreshAuthorWorkEditorChrome(); +} + +function renderCharacterEditor() { + const characters = getActiveDraftCharacters(); + if (!dom.authorCharacterSelect) return; + if (!characters.length) { + dom.authorCharacterSelect.innerHTML = ""; + dom.authorCharacterName.value = ""; + dom.authorCharacterRole.value = ""; + dom.authorCharacterLifeTheme.value = ""; + dom.authorCharacterCoreWound.value = ""; + dom.authorCharacterPublicSelf.value = ""; + dom.authorCharacterShadowDesire.value = ""; + dom.authorCharacterVows.value = ""; + if (dom.authorCharacterSummary) { + clearNode(dom.authorCharacterSummary, "选择角色后,这里会先显示当前角色摘要。"); + } + return; + } + dom.authorCharacterSelect.innerHTML = characters + .map((character, index) => ``) + .join(""); + const character = characters[Math.min(selectedCharacterIndex(), characters.length - 1)]; + dom.authorCharacterSelect.value = String(Math.min(selectedCharacterIndex(), characters.length - 1)); + dom.authorCharacterName.value = character.display_name || ""; + dom.authorCharacterRole.value = character.role || ""; + dom.authorCharacterLifeTheme.value = character.destiny_contract?.life_theme || ""; + dom.authorCharacterCoreWound.value = character.wound_profile?.core_wound || ""; + dom.authorCharacterPublicSelf.value = character.wound_profile?.public_self || ""; + dom.authorCharacterShadowDesire.value = character.wound_profile?.shadow_desire || ""; + dom.authorCharacterVows.value = (character.vow_profile?.vows || []).join("\n"); + if (dom.authorCharacterSummary) { + const cards = []; + const repairLoopCard = createRepairLoopSummaryCard("character_card"); + if (repairLoopCard) { + cards.push(repairLoopCard); + } + cards.push(createAuthorSummaryCard({ + title: character.display_name || character.character_id || "当前角色", + score: character.role || "-", + body: + `命题 ${character.destiny_contract?.life_theme || "-"}\n` + + `创伤 ${character.wound_profile?.core_wound || "-"}\n` + + `表面自我 ${character.wound_profile?.public_self || "-"}\n` + + `誓约 ${(character.vow_profile?.vows || []).slice(0, 2).join(" / ") || "-"}`, + })); + appendAuthorCardGrid(dom.authorCharacterSummary, cards); + } +} + +function renderSceneEditor() { + const scenes = getActiveDraftScenes(); + if (!dom.authorSceneSelect) return; + if (!scenes.length) { + dom.authorSceneSelect.innerHTML = ""; + dom.authorSceneId.value = ""; + dom.authorSceneFunction.value = ""; + dom.authorSceneRequiredRoles.value = ""; + dom.authorSceneBeats.value = ""; + if (dom.authorSceneSummary) { + clearNode(dom.authorSceneSummary, "选择场景后,这里会先显示当前场景摘要。"); + } + return; + } + dom.authorSceneSelect.innerHTML = scenes + .map((scene, index) => ``) + .join(""); + const scene = scenes[Math.min(selectedSceneIndex(), scenes.length - 1)]; + dom.authorSceneSelect.value = String(Math.min(selectedSceneIndex(), scenes.length - 1)); + dom.authorSceneId.value = scene.scene_id || ""; + dom.authorSceneFunction.value = scene.scene_function || ""; + dom.authorSceneRequiredRoles.value = (scene.required_roles || []).join("\n"); + dom.authorSceneBeats.value = (scene.beats_template || []).join("\n"); + if (dom.authorSceneSummary) { + const cards = []; + const repairLoopCard = createRepairLoopSummaryCard("scene_blueprint"); + if (repairLoopCard) { + cards.push(repairLoopCard); + } + cards.push(createAuthorSummaryCard({ + title: scene.scene_id || `scene_${Math.min(selectedSceneIndex(), scenes.length - 1) + 1}`, + score: scene.scene_function || "-", + body: + `必要角色 ${(scene.required_roles || []).join(" / ") || "-"}\n` + + `beats ${(scene.beats_template || []).slice(0, 3).join(" / ") || "-"}\n` + + `下一步 改完场景蓝图后去 Review 看 diff。`, + })); + appendAuthorCardGrid(dom.authorSceneSummary, cards); + } +} + +function renderLongformWorkbench() { + if (!dom.authorLongformStatus) return; + const worldpack = getActiveDraftWorldpack(); + const detail = authorState.activeDraftDetail || {}; + const seriesPlan = getActiveSeriesPlan(); + const volumePlans = getActiveVolumePlans(); + const arcPlans = getActiveArcPlans(); + if (!worldpack || !authorState.activeDraftVersionId) { + clearNode(dom.authorLongformStatus, "选择一个草稿后,这里会显示当前长篇规划。"); + if (dom.authorLongformSummary) { + clearNode(dom.authorLongformSummary, "这里会先显示当前规划对象、计划来源和建议下一步。"); + } + if (dom.authorArcBoard) clearNode(dom.authorArcBoard, "这里会显示按分卷组织的弧线看板。"); + if (dom.authorTaskBoard) clearNode(dom.authorTaskBoard, "这里会显示当前弧线下可拖拽排序的章节任务。"); + if (dom.authorVolumeSelect) dom.authorVolumeSelect.innerHTML = ""; + if (dom.authorArcSelect) dom.authorArcSelect.innerHTML = ""; + if (dom.authorTaskSelect) dom.authorTaskSelect.innerHTML = ""; + if (dom.authorTaskId) dom.authorTaskId.value = ""; + if (dom.authorTaskDuty) dom.authorTaskDuty.value = "advance_plot"; + if (dom.authorTaskObjective) dom.authorTaskObjective.value = ""; + if (dom.authorTaskTargetWords) dom.authorTaskTargetWords.value = ""; + if (dom.authorTaskRevealBudget) dom.authorTaskRevealBudget.value = ""; + if (dom.authorTaskPromiseActions) dom.authorTaskPromiseActions.value = ""; + if (dom.authorTaskPromiseTargets) dom.authorTaskPromiseTargets.value = ""; + if (dom.authorTaskAllowTerminal) dom.authorTaskAllowTerminal.checked = false; + if (dom.authorTaskBulkState) dom.authorTaskBulkState.value = ""; + if (dom.authorTaskBulkIssues) dom.authorTaskBulkIssues.value = ""; + if (dom.authorTaskBulkNotes) dom.authorTaskBulkNotes.value = ""; + if (dom.authorArcTaskPreview) dom.authorArcTaskPreview.value = ""; + return; + } + const snapshot = detail.simulation_report?.longform_plan_snapshot || {}; + const planSource = snapshot.plan_source || (seriesPlan ? "worldpack" : "missing"); + if (!seriesPlan || !volumePlans.length || !arcPlans.length) { + if (dom.authorLongformSummary) { + appendAuthorCardGrid(dom.authorLongformSummary, [ + createAuthorSummaryCard({ + title: "长篇规划尚未建立", + score: planSource, + body: + `当前草稿 ${worldpack.title || authorState.activeDraftVersionId}\n` + + `计划来源 ${planSource}\n` + + `下一步 先生成长篇规划,再进入分卷、弧线和章节任务编辑。`, + actionLabel: "生成长篇规划", + onAction: () => { + if (dom.authorBootstrapLongform) dom.authorBootstrapLongform.click(); + }, + primary: true, + }), + ]); + } + clearNode( + dom.authorLongformStatus, + `当前草稿还没有建立长篇规划。\n计划来源:${planSource}\n点击“生成长篇规划”后即可进入系列、分卷与弧线编辑。` + ); + if (dom.authorSeriesTitle) dom.authorSeriesTitle.value = worldpack.title || ""; + if (dom.authorSeriesTheme) dom.authorSeriesTheme.value = worldpack.metadata?.author_brief?.life_theme || ""; + if (dom.authorSeriesTotalVolumes) dom.authorSeriesTotalVolumes.value = ""; + if (dom.authorSeriesTotalChapters) dom.authorSeriesTotalChapters.value = ""; + if (dom.authorSeriesTargetWords) dom.authorSeriesTargetWords.value = ""; + if (dom.authorStorylineContract) dom.authorStorylineContract.value = ""; + if (dom.authorCharacterMemoryProfiles) dom.authorCharacterMemoryProfiles.value = ""; + if (dom.authorSteeringGuardrails) dom.authorSteeringGuardrails.value = ""; + if (dom.authorArcBoard) clearNode(dom.authorArcBoard, "当前草稿还没有建立长篇规划。"); + if (dom.authorTaskBoard) clearNode(dom.authorTaskBoard, "当前草稿还没有建立长篇规划。"); + if (dom.authorVolumeSelect) dom.authorVolumeSelect.innerHTML = ""; + if (dom.authorVolumeTitle) dom.authorVolumeTitle.value = ""; + if (dom.authorVolumeGoal) dom.authorVolumeGoal.value = ""; + if (dom.authorVolumeTargetChapters) dom.authorVolumeTargetChapters.value = ""; + if (dom.authorVolumeClimax) dom.authorVolumeClimax.value = ""; + if (dom.authorVolumeEndState) dom.authorVolumeEndState.value = ""; + if (dom.authorArcSelect) dom.authorArcSelect.innerHTML = ""; + if (dom.authorTaskSelect) dom.authorTaskSelect.innerHTML = ""; + if (dom.authorTaskId) dom.authorTaskId.value = ""; + if (dom.authorTaskDuty) dom.authorTaskDuty.value = "advance_plot"; + if (dom.authorTaskObjective) dom.authorTaskObjective.value = ""; + if (dom.authorTaskTargetWords) dom.authorTaskTargetWords.value = ""; + if (dom.authorTaskRevealBudget) dom.authorTaskRevealBudget.value = ""; + if (dom.authorTaskPromiseActions) dom.authorTaskPromiseActions.value = ""; + if (dom.authorTaskPromiseTargets) dom.authorTaskPromiseTargets.value = ""; + if (dom.authorTaskAllowTerminal) dom.authorTaskAllowTerminal.checked = false; + if (dom.authorTaskBulkState) dom.authorTaskBulkState.value = ""; + if (dom.authorTaskBulkIssues) dom.authorTaskBulkIssues.value = ""; + if (dom.authorTaskBulkNotes) dom.authorTaskBulkNotes.value = ""; + if (dom.authorArcTitle) dom.authorArcTitle.value = ""; + if (dom.authorArcGoal) dom.authorArcGoal.value = ""; + if (dom.authorArcConflict) dom.authorArcConflict.value = ""; + if (dom.authorArcTargetChapters) dom.authorArcTargetChapters.value = ""; + if (dom.authorArcRevealBudget) dom.authorArcRevealBudget.value = ""; + if (dom.authorArcPayoffTargets) dom.authorArcPayoffTargets.value = ""; + if (dom.authorArcCompletionConditions) dom.authorArcCompletionConditions.value = ""; + if (dom.authorArcTaskPreview) dom.authorArcTaskPreview.value = ""; + return; + } + + if (dom.authorLongformSummary) { + const selectedVolume = + volumePlans.find((item) => item.volume_id === authorState.selectedAuthorVolumeId) || + volumePlans[0] || + null; + const selectedArc = + arcPlans.find((item) => item.arc_id === authorState.selectedAuthorArcId) || + arcPlans[0] || + null; + const selectedTask = + (selectedArc?.chapter_tasks || []).find((item) => item.chapter_task_id === authorState.selectedAuthorTaskId) || + selectedArc?.chapter_tasks?.[0] || + null; + const cards = []; + const repairLoopCard = createRepairLoopSummaryCard("chapter_task"); + if (repairLoopCard) { + cards.push(repairLoopCard); + } + cards.push(createAuthorSummaryCard({ + title: seriesPlan.title || worldpack.title || "当前系列", + score: `${volumePlans.length} 卷 / ${arcPlans.length} 弧线`, + body: + `主题 ${seriesPlan.theme_statement || worldpack.metadata?.author_brief?.life_theme || "-"}\n` + + `分卷 ${selectedVolume?.title || "-"}\n` + + `弧线 ${selectedArc?.title || "-"}\n` + + `下一步 先确认当前分卷和弧线,再细化章节任务。`, + })); + cards.push(createAuthorSummaryCard({ + title: "当前章节任务", + score: selectedTask?.duty_type || "待选择", + body: + `任务 ${selectedTask?.chapter_task_id || "-"}\n` + + `目标 ${selectedTask?.objective || "-"}\n` + + `关联承诺 ${(selectedTask?.promise_targets || []).join(" / ") || "-"}\n` + + `下一步 把当前章节任务连回承诺映射与修稿桥。`, + })); + appendAuthorCardGrid(dom.authorLongformSummary, cards); + } + + clearNode(dom.authorLongformStatus); + const statusCard = createListCard({ + title: "长篇规划状态", + score: planSource, + body: + `系列 ${seriesPlan.series_id || "-"} · 标题 ${seriesPlan.title || "-"}\n` + + `分卷 ${volumePlans.length}/${seriesPlan.total_volume_target || "-"} · 弧线 ${arcPlans.length}\n` + + `章节 ${seriesPlan.total_chapter_target || "-"} · 字数 ${seriesPlan.target_word_count || "-"}\n` + + `当前阶段 ${worldpack.metadata?.longform_program_stage || "-"}` + }); + dom.authorLongformStatus.appendChild(statusCard); + + if (!authorState.selectedAuthorVolumeId || !volumePlans.some((item) => item.volume_id === authorState.selectedAuthorVolumeId)) { + authorState.selectedAuthorVolumeId = volumePlans[0]?.volume_id || null; + } + const selectedVolume = volumePlans.find((item) => item.volume_id === authorState.selectedAuthorVolumeId) || volumePlans[0]; + const arcsForVolume = arcPlans + .filter((item) => item.volume_id === selectedVolume?.volume_id) + .sort((left, right) => Number(left.order || 0) - Number(right.order || 0)); + if (!authorState.selectedAuthorArcId || !arcsForVolume.some((item) => item.arc_id === authorState.selectedAuthorArcId)) { + authorState.selectedAuthorArcId = arcsForVolume[0]?.arc_id || null; + } + const selectedArc = arcsForVolume.find((item) => item.arc_id === authorState.selectedAuthorArcId) || arcsForVolume[0]; + const selectedTasks = Array.isArray(selectedArc?.chapter_tasks) ? selectedArc.chapter_tasks : []; + if (!authorState.selectedAuthorTaskId || !selectedTasks.some((item) => item.chapter_task_id === authorState.selectedAuthorTaskId)) { + authorState.selectedAuthorTaskId = selectedTasks[0]?.chapter_task_id || null; + } + const selectedTask = selectedTasks.find((item) => item.chapter_task_id === authorState.selectedAuthorTaskId) || selectedTasks[0]; + + if (dom.authorSeriesTitle) dom.authorSeriesTitle.value = seriesPlan.title || ""; + if (dom.authorSeriesTheme) dom.authorSeriesTheme.value = seriesPlan.theme_statement || ""; + if (dom.authorSeriesTotalVolumes) dom.authorSeriesTotalVolumes.value = String(Number(seriesPlan.total_volume_target || volumePlans.length || 1)); + if (dom.authorSeriesTotalChapters) dom.authorSeriesTotalChapters.value = String(Number(seriesPlan.total_chapter_target || 0)); + if (dom.authorSeriesTargetWords) dom.authorSeriesTargetWords.value = String(Number(seriesPlan.target_word_count || 0)); + if (dom.authorStorylineContract) { + dom.authorStorylineContract.value = JSON.stringify(worldpack.series_storyline_contract || {}, null, 2); + } + if (dom.authorCharacterMemoryProfiles) { + dom.authorCharacterMemoryProfiles.value = JSON.stringify(worldpack.character_memory_profiles || {}, null, 2); + } + if (dom.authorSteeringGuardrails) { + dom.authorSteeringGuardrails.value = JSON.stringify(worldpack.steering_guardrails || {}, null, 2); + } + + if (dom.authorVolumeSelect) { + dom.authorVolumeSelect.innerHTML = volumePlans + .map((volume) => ``) + .join(""); + dom.authorVolumeSelect.value = selectedVolume?.volume_id || ""; + } + if (selectedVolume) { + if (dom.authorVolumeTitle) dom.authorVolumeTitle.value = selectedVolume.title || ""; + if (dom.authorVolumeGoal) dom.authorVolumeGoal.value = selectedVolume.goal || ""; + if (dom.authorVolumeTargetChapters) dom.authorVolumeTargetChapters.value = String(Number(selectedVolume.target_chapters || 1)); + if (dom.authorVolumeClimax) dom.authorVolumeClimax.value = selectedVolume.climax_definition || ""; + if (dom.authorVolumeEndState) dom.authorVolumeEndState.value = selectedVolume.end_state || ""; + } + + if (dom.authorArcSelect) { + dom.authorArcSelect.innerHTML = arcsForVolume + .map((arc) => ``) + .join(""); + dom.authorArcSelect.value = selectedArc?.arc_id || ""; + } + if (selectedArc) { + if (dom.authorArcTitle) dom.authorArcTitle.value = selectedArc.title || ""; + if (dom.authorArcGoal) dom.authorArcGoal.value = selectedArc.goal || ""; + if (dom.authorArcConflict) dom.authorArcConflict.value = selectedArc.conflict || ""; + if (dom.authorArcTargetChapters) dom.authorArcTargetChapters.value = String(Number(selectedArc.target_chapters || 1)); + if (dom.authorArcRevealBudget) dom.authorArcRevealBudget.value = String(Number(selectedArc.reveal_budget || 0)); + if (dom.authorArcPayoffTargets) dom.authorArcPayoffTargets.value = (selectedArc.payoff_targets || []).join("\n"); + if (dom.authorArcCompletionConditions) dom.authorArcCompletionConditions.value = (selectedArc.completion_conditions || []).join("\n"); + if (dom.authorArcTaskPreview) { + dom.authorArcTaskPreview.value = (selectedArc.chapter_tasks || []) + .map((task, index) => `${index + 1}. ${task.duty_type || "-"} · ${task.objective || "-"} · words ${task.target_words || "-"}`) + .join("\n"); + } + } else if (dom.authorArcTaskPreview) { + dom.authorArcTaskPreview.value = ""; + } + if (dom.authorArcBoard) { + clearNode(dom.authorArcBoard); + const groupedByVolume = volumePlans.map((volume) => ({ + volume, + arcs: arcPlans + .filter((arc) => arc.volume_id === volume.volume_id) + .sort((left, right) => Number(left.order || 0) - Number(right.order || 0)), + })); + groupedByVolume.forEach(({ volume, arcs }) => { + const card = createListCard({ + title: `${volume.title || volume.volume_id}`, + score: `${arcs.length} arcs · 可拖拽重排`, + body: + `目标章节 ${volume.target_chapters || "-"} · 当前卷目标 ${volume.goal || "-"}` + }); + const list = document.createElement("div"); + list.className = "list-stack"; + arcs.forEach((arc) => { + const arcCard = createListCard({ + title: `${arc.arc_id === selectedArc?.arc_id ? ">> " : ""}#${arc.order || "-"} ${arc.title || arc.arc_id}`, + score: `${(arc.chapter_tasks || []).length} tasks`, + body: + `${arc.goal || "-"}\nchapters ${arc.target_chapters || "-"} · reveal ${arc.reveal_budget || "-"} · conflict ${arc.conflict || "-"}` + }); + arcCard.draggable = true; + arcCard.addEventListener("click", () => { + authorState.selectedAuthorVolumeId = volume.volume_id; + authorState.selectedAuthorArcId = arc.arc_id; + authorState.selectedAuthorTaskId = arc.chapter_tasks?.[0]?.chapter_task_id || null; + renderLongformWorkbench(); + }); + arcCard.addEventListener("dragstart", (event) => { + authorState.draggingAuthorArcId = arc.arc_id; + event.dataTransfer?.setData("text/plain", arc.arc_id); + event.dataTransfer.effectAllowed = "move"; + }); + arcCard.addEventListener("dragover", (event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }); + arcCard.addEventListener("drop", (event) => { + event.preventDefault(); + const draggedArcId = authorState.draggingAuthorArcId || event.dataTransfer?.getData("text/plain") || ""; + reorderArcWithinVolume(volume.volume_id, draggedArcId, arc.arc_id); + authorState.draggingAuthorArcId = null; + }); + arcCard.addEventListener("dragend", () => { + authorState.draggingAuthorArcId = null; + }); + list.appendChild(arcCard); + }); + card.appendChild(list); + dom.authorArcBoard.appendChild(card); + }); + } + if (dom.authorTaskSelect) { + dom.authorTaskSelect.innerHTML = selectedTasks + .map((task, index) => ``) + .join(""); + dom.authorTaskSelect.value = selectedTask?.chapter_task_id || ""; + } + if (dom.authorTaskBoard) { + clearNode(dom.authorTaskBoard); + if (!arcsForVolume.length) { + clearNode(dom.authorTaskBoard, "当前 arc 还没有 chapter tasks。"); + } else { + arcsForVolume.forEach((arc) => { + const arcTasks = Array.isArray(arc.chapter_tasks) ? arc.chapter_tasks : []; + const arcCard = createListCard({ + title: `${arc.arc_id === selectedArc?.arc_id ? ">> " : ""}${arc.title || arc.arc_id}`, + score: `${arcTasks.length} tasks`, + body: `${arc.goal || "-"}\nchapters ${arc.target_chapters || "-"} · reveal ${arc.reveal_budget || "-"}` + }); + const taskList = document.createElement("div"); + taskList.className = "list-stack"; + taskList.addEventListener("dragover", (event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }); + taskList.addEventListener("drop", (event) => { + event.preventDefault(); + const draggedTaskId = authorState.draggingAuthorTaskId || event.dataTransfer?.getData("text/plain") || ""; + moveTaskAcrossArcs(authorState.selectedAuthorArcId || arc.arc_id, arc.arc_id, draggedTaskId); + authorState.draggingAuthorTaskId = null; + }); + arcTasks.forEach((task, index) => { + const taskCard = createListCard({ + title: `${task.chapter_task_id === selectedTask?.chapter_task_id ? ">> " : ""}#${index + 1} ${task.duty_type || "-"}`, + score: `${task.target_words || "-"} words`, + body: + `${task.objective || "-"}\nreveal ${task.reveal_budget || 0} · actions ${(task.promise_actions || []).join(" / ") || "-"}\ntargets ${(task.promise_targets || []).join(" / ") || "-"}${task.allow_terminal ? "\nterminal allowed" : ""}` + }); + taskCard.draggable = true; + taskCard.addEventListener("click", () => { + authorState.selectedAuthorArcId = arc.arc_id; + authorState.selectedAuthorTaskId = task.chapter_task_id; + if (dom.authorTaskBulkIssues) dom.authorTaskBulkIssues.value = ""; + if (dom.authorTaskBulkNotes) dom.authorTaskBulkNotes.value = ""; + renderLongformWorkbench(); + }); + taskCard.addEventListener("dragstart", (event) => { + authorState.draggingAuthorTaskId = task.chapter_task_id; + authorState.selectedAuthorArcId = arc.arc_id; + event.dataTransfer?.setData("text/plain", task.chapter_task_id); + event.dataTransfer.effectAllowed = "move"; + }); + taskCard.addEventListener("dragover", (event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }); + taskCard.addEventListener("drop", (event) => { + event.preventDefault(); + const draggedTaskId = authorState.draggingAuthorTaskId || event.dataTransfer?.getData("text/plain") || ""; + moveTaskAcrossArcs(authorState.selectedAuthorArcId || arc.arc_id, arc.arc_id, draggedTaskId, task.chapter_task_id); + authorState.draggingAuthorTaskId = null; + }); + taskCard.addEventListener("dragend", () => { + authorState.draggingAuthorTaskId = null; + }); + taskList.appendChild(taskCard); + }); + arcCard.appendChild(taskList); + dom.authorTaskBoard.appendChild(arcCard); + }); + } + } + if (selectedTask) { + if (dom.authorTaskId) dom.authorTaskId.value = selectedTask.chapter_task_id || ""; + if (dom.authorTaskDuty) dom.authorTaskDuty.value = selectedTask.duty_type || "advance_plot"; + if (dom.authorTaskObjective) dom.authorTaskObjective.value = selectedTask.objective || ""; + if (dom.authorTaskTargetWords) dom.authorTaskTargetWords.value = String(Number(selectedTask.target_words || 2000)); + if (dom.authorTaskRevealBudget) dom.authorTaskRevealBudget.value = String(Number(selectedTask.reveal_budget || 0)); + if (dom.authorTaskPromiseActions) dom.authorTaskPromiseActions.value = (selectedTask.promise_actions || []).join("\n"); + if (dom.authorTaskPromiseTargets) dom.authorTaskPromiseTargets.value = (selectedTask.promise_targets || []).join("\n"); + if (dom.authorTaskAllowTerminal) dom.authorTaskAllowTerminal.checked = Boolean(selectedTask.allow_terminal); + } else { + if (dom.authorTaskId) dom.authorTaskId.value = ""; + if (dom.authorTaskDuty) dom.authorTaskDuty.value = "advance_plot"; + if (dom.authorTaskObjective) dom.authorTaskObjective.value = ""; + if (dom.authorTaskTargetWords) dom.authorTaskTargetWords.value = ""; + if (dom.authorTaskRevealBudget) dom.authorTaskRevealBudget.value = ""; + if (dom.authorTaskPromiseActions) dom.authorTaskPromiseActions.value = ""; + if (dom.authorTaskPromiseTargets) dom.authorTaskPromiseTargets.value = ""; + if (dom.authorTaskAllowTerminal) dom.authorTaskAllowTerminal.checked = false; + } + renderSeriesVolumeArcPromiseMapping(); + renderChapterTaskSimulationLinking(); +} + +function renderPromiseLedgerWorkbench() { + if (!dom.authorPromiseLedger) return; + clearNode(dom.authorPromiseLedger); + const ledger = getPromiseLedgerWorkbench(); + const promiseState = getPromiseStateWorkbench(); + if (!ledger.available) { + clearNode(dom.authorPromiseLedger, "运行 simulation 后,这里会显示 open / overdue / closed promises。"); + if (dom.authorPromiseSelect) dom.authorPromiseSelect.innerHTML = ""; + if (dom.authorPromiseState) dom.authorPromiseState.value = ""; + if (dom.authorPromiseNotes) dom.authorPromiseNotes.value = ""; + return; + } + const editablePromises = Array.isArray(promiseState.editable_promises) ? promiseState.editable_promises : []; + if (!authorState.selectedAuthorPromiseId || !editablePromises.some((item) => item.promise_id === authorState.selectedAuthorPromiseId)) { + authorState.selectedAuthorPromiseId = editablePromises[0]?.promise_id || ledger.open_promises?.[0]?.promise_id || null; + } + const selectedPromise = + editablePromises.find((item) => item.promise_id === authorState.selectedAuthorPromiseId) || + (ledger.open_promises || []).find((item) => item.promise_id === authorState.selectedAuthorPromiseId) || + editablePromises[0] || + (ledger.open_promises || [])[0]; + if (dom.authorPromiseSelect) { + dom.authorPromiseSelect.innerHTML = editablePromises + .map( + (item) => + `` + ) + .join(""); + dom.authorPromiseSelect.value = selectedPromise?.promise_id || ""; + } + if (dom.authorPromiseState) { + dom.authorPromiseState.innerHTML = [``, ...(promiseState.state_options || []).map((item) => ``)].join(""); + dom.authorPromiseState.value = selectedPromise?.editor_state || ""; + } + if (dom.authorPromiseNotes) { + dom.authorPromiseNotes.value = selectedPromise?.editor_notes || ""; + } + dom.authorPromiseLedger.appendChild( + createListCard({ + title: "Promise Ledger Summary", + score: ledger.status || "-", + body: + `open ${ledger.open_count ?? 0} · overdue ${ledger.overdue_count ?? 0} · closed ${ledger.closed_count ?? 0}\n` + + `editor overrides ${promiseState.override_count ?? 0}\n` + + `next actions ${(ledger.next_actions || []).join(" / ") || "-"}` + }) + ); + if (selectedPromise) { + dom.authorPromiseLedger.appendChild( + createListCard({ + title: "Selected Promise", + score: selectedPromise.editor_state || selectedPromise.status || "-", + body: + `${selectedPromise.promise_id}\n${selectedPromise.description || "-"}\n` + + `holders ${(selectedPromise.holders || []).join(" / ") || "-"} · stakes ${selectedPromise.stakes || "-"}\n` + + `chapters ${selectedPromise.first_seen_chapter ?? "-"} -> ${selectedPromise.last_seen_chapter ?? "-"}${selectedPromise.is_overdue ? " · overdue" : ""}\n` + + `notes ${selectedPromise.editor_notes || "-"}` + }) + ); + } + if ((ledger.open_promises || []).length) { + dom.authorPromiseLedger.appendChild( + createListCard({ + title: "Open Promises", + score: `${ledger.open_promises.length} open`, + body: + `${(ledger.open_promises || []).map((item) => `${selectedAuthorChapterMarker(item.last_seen_chapter)}${item.promise_id}\n${item.description || "-"}\nholders ${(item.holders || []).join(" / ") || "-"} · stakes ${item.stakes || "-"} · due ${item.due_by_turn ?? "-"}${item.is_overdue ? " · overdue" : ""}\nchapters ${item.first_seen_chapter ?? "-"} -> ${item.last_seen_chapter ?? "-"}`).join("\n\n")}` + }) + ); + const firstPromise = ledger.open_promises[0]; + if (firstPromise?.anchor?.anchor_key) { + const actions = document.createElement("div"); + actions.className = "composer-actions"; + const jumpButton = document.createElement("button"); + jumpButton.className = "ghost-action"; + jumpButton.textContent = "跳到首个 Promise 章节"; + jumpButton.addEventListener("click", () => { + jumpToAuthorChapter(firstPromise.anchor.anchor_key, "simulation"); + }); + actions.appendChild(jumpButton); + const button = document.createElement("button"); + button.className = "ghost-action"; + button.textContent = "评论首个 Promise"; + button.addEventListener("click", () => { + prefillAuthorCommentAnchor(firstPromise.anchor.anchor_type || "simulation", String(firstPromise.anchor.anchor_key)); + }); + actions.appendChild(button); + dom.authorPromiseLedger.appendChild(actions); + } + } else { + dom.authorPromiseLedger.appendChild( + createListCard({ + title: "Open Promises", + score: "0", + body: "当前 simulation 没有未结 promise。" + }) + ); + } + if ((ledger.recently_closed_ids || []).length) { + dom.authorPromiseLedger.appendChild( + createListCard({ + title: "Recently Closed", + score: `${ledger.recently_closed_ids.length} ids`, + body: (ledger.recently_closed_ids || []).join("\n") + }) + ); + } +} + +function renderSeriesVolumeArcPromiseMapping() { + if (!dom.authorPromiseMapping) return; + clearNode(dom.authorPromiseMapping); + const mapping = getSeriesVolumeArcPromiseMapping(); + if (!mapping.available) { + clearNode(dom.authorPromiseMapping, "运行 simulation 后,这里会显示 series / volume / arc 对 promises 的映射。"); + return; + } + const seriesSummary = mapping.series_summary || {}; + const volumes = Array.isArray(mapping.volumes) ? mapping.volumes : []; + const arcs = Array.isArray(mapping.arcs) ? mapping.arcs : []; + const selectedVolume = volumes.find((item) => item.volume_id === authorState.selectedAuthorVolumeId) || volumes[0]; + const selectedArc = + arcs.find((item) => item.arc_id === authorState.selectedAuthorArcId) || + arcs.find((item) => item.volume_id === selectedVolume?.volume_id) || + arcs[0]; + + dom.authorPromiseMapping.appendChild( + createListCard({ + title: "Series Promise Map", + score: `${seriesSummary.mapped_promises?.length || 0} promises`, + body: + `series ${seriesSummary.title || seriesSummary.series_id || "-"}\n` + + `simulated chapters ${seriesSummary.simulated_chapter_count ?? 0}/${seriesSummary.target_chapters ?? "-"}\n` + + `open ${seriesSummary.open_promise_ids?.length ?? 0} · closed ${seriesSummary.closed_promise_ids?.length ?? 0}\n` + + `next actions ${(mapping.next_actions || []).join(" / ") || "-"}` + }) + ); + + if (selectedVolume) { + dom.authorPromiseMapping.appendChild( + createListCard({ + title: "Selected Volume Promise Map", + score: `${selectedVolume.mapped_promises?.length || 0} promises`, + body: + `${selectedVolume.title || selectedVolume.volume_id}\n` + + `chapters ${selectedVolume.first_simulation_chapter ?? "-"} -> ${selectedVolume.last_simulation_chapter ?? "-"} · simulated ${selectedVolume.simulated_chapter_count ?? 0}\n` + + `open ${selectedVolume.open_promise_ids?.length ?? 0} · closed ${selectedVolume.closed_promise_ids?.length ?? 0}\n` + + `arcs ${(selectedVolume.arc_ids || []).join(" / ") || "-"}` + }) + ); + } + + if (selectedArc) { + dom.authorPromiseMapping.appendChild( + createListCard({ + title: "Selected Arc Promise Map", + score: `${selectedArc.mapped_promises?.length || 0} promises`, + body: + `${selectedArc.title || selectedArc.arc_id}\n` + + `goal ${selectedArc.goal || "-"}\n` + + `chapters ${selectedArc.first_simulation_chapter ?? "-"} -> ${selectedArc.last_simulation_chapter ?? "-"} · simulated ${selectedArc.simulated_chapter_count ?? 0}\n` + + `open ${selectedArc.open_promise_ids?.length ?? 0} · closed ${selectedArc.closed_promise_ids?.length ?? 0}` + }) + ); + dom.authorPromiseMapping.appendChild( + createListCard({ + title: "Arc Mapped Promises", + score: `${selectedArc.mapped_promises?.length || 0} items`, + body: + `${(selectedArc.mapped_promises || []) + .map( + (item) => + `${item.promise_id}\n${item.description || "-"}\nstatus ${item.status || "-"} · editor ${item.editor_state || "-"} · holders ${(item.holders || []).join(" / ") || "-"} · chapters ${item.first_seen_chapter ?? "-"} -> ${item.last_seen_chapter ?? "-"}${item.is_overdue ? " · overdue" : ""}` + ) + .join("\n\n") || "当前 arc 还没有映射到 promises。"}` + }) + ); + const firstPromise = (selectedArc.mapped_promises || [])[0]; + if (firstPromise?.anchor?.anchor_key) { + const actions = document.createElement("div"); + actions.className = "composer-actions"; + const jumpPromise = document.createElement("button"); + jumpPromise.className = "ghost-action"; + jumpPromise.textContent = "跳到当前 Arc 的首个 Promise"; + jumpPromise.addEventListener("click", () => { + jumpToAuthorChapter(firstPromise.anchor.anchor_key, "simulation"); + }); + actions.appendChild(jumpPromise); + const commentPromise = document.createElement("button"); + commentPromise.className = "ghost-action"; + commentPromise.textContent = "评论当前 Arc 的首个 Promise"; + commentPromise.addEventListener("click", () => { + prefillAuthorCommentAnchor(firstPromise.anchor.anchor_type || "simulation", String(firstPromise.anchor.anchor_key)); + }); + actions.appendChild(commentPromise); + dom.authorPromiseMapping.appendChild(actions); + } + } +} + +function renderChapterTaskSimulationLinking() { + if (!dom.authorTaskSimulationLinking) return; + clearNode(dom.authorTaskSimulationLinking); + const linking = getChapterTaskSimulationLinking(); + if (!linking.available) { + clearNode(dom.authorTaskSimulationLinking, "运行 simulation 后,这里会显示当前 chapter task 对应的章节链接和 promise 影响。"); + if (dom.authorTaskBulkState) dom.authorTaskBulkState.value = ""; + if (dom.authorTaskBulkIssues) dom.authorTaskBulkIssues.value = ""; + if (dom.authorTaskBulkNotes) dom.authorTaskBulkNotes.value = ""; + return; + } + const taskLinks = Array.isArray(linking.task_links) ? linking.task_links : []; + const selectedLink = + taskLinks.find((item) => item.chapter_task_id === authorState.selectedAuthorTaskId) || + taskLinks.find((item) => item.arc_id === authorState.selectedAuthorArcId) || + taskLinks[0]; + if (dom.authorTaskBulkState && !dom.authorTaskBulkState.value) { + dom.authorTaskBulkState.value = ""; + } + if (dom.authorTaskBulkIssues && !dom.authorTaskBulkIssues.value) { + dom.authorTaskBulkIssues.value = (selectedLink?.compare_summary?.issue_codes_added || []).join("\n"); + } + + dom.authorTaskSimulationLinking.appendChild( + createListCard({ + title: "Task Linking Summary", + score: `${linking.linked_task_count ?? 0} linked`, + body: + `linked ${linking.linked_task_count ?? 0} · planned only ${linking.planned_only_task_count ?? 0}\n` + + `next actions ${(linking.next_actions || []).join(" / ") || "-"}` + }) + ); + + if (!selectedLink) { + dom.authorTaskSimulationLinking.appendChild( + createListCard({ + title: "Selected Task", + score: "missing", + body: "当前没有可用的 chapter task linking。" + }) + ); + return; + } + + dom.authorTaskSimulationLinking.appendChild( + createListCard({ + title: "Selected Task", + score: selectedLink.status || "-", + body: + `${selectedLink.chapter_task_id}\n` + + `${selectedLink.duty_type || "-"} · ${selectedLink.arc_title || selectedLink.arc_id || "-"}\n` + + `objective ${selectedLink.objective || "-"}\n` + + `simulated chapters ${selectedLink.simulated_chapter_count ?? 0} · compared ${selectedLink.compare_summary?.compared_chapter_count ?? 0} · promises ${(selectedLink.mapped_promises || []).length}\n` + + `actions ${(selectedLink.promise_actions || []).join(" / ") || "-"}\n` + + `targets ${(selectedLink.promise_targets || []).join(" / ") || "-"}` + }) + ); + + dom.authorTaskSimulationLinking.appendChild( + createListCard({ + title: "Task Compare Diff", + score: selectedLink.compare_available ? "available" : "missing", + body: + `compared chapters ${selectedLink.compare_summary?.compared_chapter_count ?? 0}\n` + + `avg score delta ${Number(selectedLink.compare_summary?.average_score_delta || 0).toFixed(3)}\n` + + `issues + ${(selectedLink.compare_summary?.issue_codes_added || []).join("/") || "-"} · - ${(selectedLink.compare_summary?.issue_codes_removed || []).join("/") || "-"}\n` + + `strongest compare chapter ${selectedLink.compare_summary?.strongest_compare_chapter_index || "-"} · delta ${Number(selectedLink.compare_summary?.strongest_compare_delta || 0).toFixed(3)}` + }) + ); + + dom.authorTaskSimulationLinking.appendChild( + createListCard({ + title: "Linked Simulation Chapters", + score: `${selectedLink.linked_chapters?.length || 0} chapters`, + body: + `${(selectedLink.linked_chapters || []) + .map( + (item) => + `${selectedAuthorChapterMarker(item.chapter_index)}${item.chapter_index}. ${item.chapter_title || item.chapter_id || "-"}\n${item.scene_function || "-"} · decision ${item.decision || "-"} · score ${Number(item.overall_score || 0).toFixed(3)}\nissues ${(item.issue_codes || []).join("/") || "-"} · open ${(item.open_promise_ids || []).join("/") || "-"} · closed ${(item.closed_promise_ids || []).join("/") || "-"}` + ) + .join("\n\n") || "当前 task 还没有链接到 simulation chapters。"}` + }) + ); + + dom.authorTaskSimulationLinking.appendChild( + createListCard({ + title: "Mapped Promises", + score: `${selectedLink.mapped_promises?.length || 0} promises`, + body: + `${(selectedLink.mapped_promises || []) + .map( + (item) => + `${item.promise_id}\n${item.description || "-"}\nstatus ${item.status || "-"} · editor ${item.editor_state || "-"} · holders ${(item.holders || []).join(" / ") || "-"} · chapters ${item.first_seen_chapter ?? "-"} -> ${item.last_seen_chapter ?? "-"}` + ) + .join("\n\n") || "当前 task 还没有观测到 promise 映射。"}` + }) + ); + + dom.authorTaskSimulationLinking.appendChild( + createListCard({ + title: "Planned Promise Targets", + score: `${selectedLink.planned_promises?.length || 0} targets`, + body: + `${(selectedLink.planned_promises || []) + .map( + (item) => + `${item.promise_id}\n${item.description || "-"}\nstatus ${item.status || "-"} · editor ${item.editor_state || "-"} · chapters ${item.first_seen_chapter ?? "-"} -> ${item.last_seen_chapter ?? "-"}` + ) + .join("\n\n") || "当前 task 还没有显式 promise targets。"}` + }) + ); + + dom.authorTaskSimulationLinking.appendChild( + createListCard({ + title: "Planned vs Observed Promise Drift", + score: selectedLink.promise_drift?.status || "-", + body: + `coverage ${Number(selectedLink.promise_drift?.coverage_ratio || 0).toFixed(3)}\n` + + `planned ${selectedLink.promise_drift?.planned_target_count ?? 0} · observed ${selectedLink.promise_drift?.observed_target_count ?? 0} · matched ${selectedLink.promise_drift?.matched_target_count ?? 0}\n` + + `planned only ${(selectedLink.promise_drift?.planned_only_ids || []).join(" / ") || "-"}\n` + + `observed only ${(selectedLink.promise_drift?.observed_only_ids || []).join(" / ") || "-"}\n` + + `recommended ${(selectedLink.promise_drift?.recommended_actions || []).join(" / ") || "-"}` + }) + ); + + dom.authorTaskSimulationLinking.appendChild( + createListCard({ + title: "Drift Remediation Suggestions", + score: `${selectedLink.remediation_suggestions?.length || 0} suggestions`, + body: + `${(selectedLink.remediation_suggestions || []) + .map((item) => `${item.action}\n${item.summary || "-"}\n${item.details || "-"}`) + .join("\n\n") || "当前 task 暂无额外 remediation suggestions。"}` + }) + ); + + dom.authorTaskSimulationLinking.appendChild( + createListCard({ + title: "Task Compare Chapters", + score: `${selectedLink.compare_chapters?.length || 0} chapters`, + body: + `${(selectedLink.compare_chapters || []) + .map( + (item) => + `${selectedAuthorCompareMarker(item.chapter_index)}${item.chapter_index}. ${item.before_title || "-"} -> ${item.after_title || "-"}\nscore delta ${Number(item.overall_score_delta || 0).toFixed(3)} · issues + ${(item.issue_codes_added || []).join("/") || "-"} · - ${(item.issue_codes_removed || []).join("/") || "-"}` + ) + .join("\n\n") || "当前 task 还没有可用的 compare diff。"}` + }) + ); + + const actions = document.createElement("div"); + actions.className = "composer-actions"; + if (selectedLink.rewrite_workflow?.available) { + const rewriteButton = document.createElement("button"); + rewriteButton.className = "ghost-action"; + rewriteButton.textContent = "Apply Compare to Rewrite"; + rewriteButton.addEventListener("click", applySelectedTaskRewritePrefill); + actions.appendChild(rewriteButton); + } + const firstChapter = (selectedLink.linked_chapters || [])[0]; + const firstCompareChapter = (selectedLink.compare_chapters || [])[0]; + if (firstCompareChapter?.chapter_index) { + const jumpCompare = document.createElement("button"); + jumpCompare.className = "ghost-action"; + jumpCompare.textContent = "跳到当前 Task 的章节对照"; + jumpCompare.addEventListener("click", () => { + jumpToAuthorChapter(firstCompareChapter.chapter_index, "compare"); + }); + actions.appendChild(jumpCompare); + } + if (firstChapter?.anchor?.anchor_key) { + const jumpChapter = document.createElement("button"); + jumpChapter.className = "ghost-action"; + jumpChapter.textContent = "跳到当前 Task 的首个章节"; + jumpChapter.addEventListener("click", () => { + jumpToAuthorChapter(firstChapter.anchor.anchor_key, "simulation"); + }); + actions.appendChild(jumpChapter); + const commentChapter = document.createElement("button"); + commentChapter.className = "ghost-action"; + commentChapter.textContent = "评论当前 Task 的首个章节"; + commentChapter.addEventListener("click", () => { + prefillAuthorCommentAnchor(firstChapter.anchor.anchor_type || "simulation", String(firstChapter.anchor.anchor_key)); + }); + actions.appendChild(commentChapter); + } + const firstPromise = (selectedLink.mapped_promises || [])[0]; + if (firstPromise?.anchor?.anchor_key) { + const commentPromise = document.createElement("button"); + commentPromise.className = "ghost-action"; + commentPromise.textContent = "评论当前 Task 的首个 Promise"; + commentPromise.addEventListener("click", () => { + prefillAuthorCommentAnchor(firstPromise.anchor.anchor_type || "simulation", String(firstPromise.anchor.anchor_key)); + }); + actions.appendChild(commentPromise); + } + if (actions.childNodes.length) { + dom.authorTaskSimulationLinking.appendChild(actions); + } +} + +function renderRewritePatchPreview() { + if (!dom.authorRewritePatchPreview) return; + clearNode(dom.authorRewritePatchPreview); + const linking = getChapterTaskSimulationLinking(); + const selectedLink = + (linking.task_links || []).find((item) => item.chapter_task_id === authorState.selectedAuthorTaskId) || + (linking.task_links || []).find((item) => item.arc_id === authorState.selectedAuthorArcId) || + null; + if (!selectedLink?.rewrite_workflow?.available) { + authorState.authorCurrentRewritePatchExport = null; + clearNode(dom.authorRewritePatchPreview, "当前 task 还没有可预览的 rewrite patch。"); + return; + } + const workflow = selectedLink.rewrite_workflow; + const currentObjective = String(dom.authorTaskObjective?.value || ""); + const currentTargets = splitPromiseTargetList(dom.authorTaskPromiseTargets?.value || ""); + const currentBulkState = String(dom.authorTaskBulkState?.value || ""); + const currentIssueScope = splitPromiseTargetList(dom.authorTaskBulkIssues?.value || ""); + const currentBulkNotes = String(dom.authorTaskBulkNotes?.value || ""); + const currentPatch = { + objective: currentObjective, + promise_targets: currentTargets, + bulk_override_state: currentBulkState, + issue_scope: currentIssueScope, + bulk_notes: currentBulkNotes, + }; + const patchLines = []; + if (currentObjective !== String(workflow.suggested_task_objective || "")) { + patchLines.push(`objective\nFROM: ${currentObjective || "-"}\nTO: ${workflow.suggested_task_objective || "-"}`); + } + if (JSON.stringify(currentTargets) !== JSON.stringify(workflow.suggested_promise_targets || [])) { + patchLines.push(`promise_targets\nFROM: ${currentTargets.join(" / ") || "-"}\nTO: ${(workflow.suggested_promise_targets || []).join(" / ") || "-"}`); + } + if (currentBulkState !== String(workflow.suggested_override_state || "")) { + patchLines.push(`bulk_override_state\nFROM: ${currentBulkState || "-"}\nTO: ${workflow.suggested_override_state || "-"}`); + } + if (JSON.stringify(currentIssueScope) !== JSON.stringify(workflow.issue_scope || [])) { + patchLines.push(`issue_scope\nFROM: ${currentIssueScope.join(" / ") || "-"}\nTO: ${(workflow.issue_scope || []).join(" / ") || "-"}`); + } + if (currentBulkNotes !== String(workflow.suggested_bulk_notes || "")) { + patchLines.push(`bulk_notes\nFROM: ${currentBulkNotes || "-"}\nTO: ${workflow.suggested_bulk_notes || "-"}`); + } + dom.authorRewritePatchPreview.appendChild( + createListCard({ + title: "Rewrite Patch Preview", + score: patchLines.length ? `${patchLines.length} changes` : "in sync", + body: + `target chapter ${workflow.rewrite_target_chapter_index || "-"}\n` + + `issue scope ${(workflow.issue_scope || []).join(" / ") || "-"}\n` + + `next ${(workflow.next_actions || []).join(" / ") || "-"}\n\n` + + `${patchLines.join("\n\n") || "当前表单值已经和 rewrite suggestion 对齐。"}` + }) + ); + authorState.authorCurrentRewritePatchExport = { + task_id: selectedLink.chapter_task_id, + arc_id: selectedLink.arc_id, + volume_id: selectedLink.volume_id, + rewrite_target_chapter_index: workflow.rewrite_target_chapter_index || null, + issue_scope: workflow.issue_scope || [], + current_patch: currentPatch, + suggested_patch: { + objective: workflow.suggested_task_objective || "", + promise_targets: workflow.suggested_promise_targets || [], + bulk_override_state: workflow.suggested_override_state || "", + issue_scope: workflow.issue_scope || [], + bulk_notes: workflow.suggested_bulk_notes || "", + }, + patch_lines: patchLines, + next_actions: workflow.next_actions || [], + }; +} + +function renderSimulationDiffCheckpoint() { + if (!dom.authorSimulationDiffCheckpoint) return; + clearNode(dom.authorSimulationDiffCheckpoint); + const checkpoint = getSimulationDiffCheckpoint(); + if (!checkpoint.available) { + clearNode(dom.authorSimulationDiffCheckpoint, "这里会显示最近一次 rewrite revision 是否已经产出 simulation diff checkpoint。"); + return; + } + dom.authorSimulationDiffCheckpoint.appendChild( + createListCard({ + title: "Simulation Diff Checkpoint", + score: checkpoint.status || "-", + body: + `latest revision ${checkpoint.latest_revision_id || "-"} · ${checkpoint.latest_revision_source || "-"}\n` + + `summary ${checkpoint.latest_revision_summary || "-"}\n` + + `last simulated ${checkpoint.last_simulated_revision_id || "-"} · freshness ${checkpoint.simulation_freshness?.status || "-"}\n` + + `suggested action ${checkpoint.suggested_action || "-"} · auto suggest ${checkpoint.auto_resimulate_suggested ? "yes" : "no"}\n` + + `compare available ${checkpoint.compare_available ? "yes" : "no"} · top changed ${checkpoint.top_changed_chapter_count ?? 0}\n` + + `next ${(checkpoint.next_actions || []).join(" / ") || "-"}` + }) + ); +} + +function exportRewritePatchPreview() { + const payload = authorState.authorCurrentRewritePatchExport || null; + if (!payload) { + authorNotice("当前没有可导出的 rewrite patch。"); + return; + } + const suffix = payload.task_id ? String(payload.task_id).replace(/[^a-zA-Z0-9_.-]+/g, "_") : "rewrite_patch"; + downloadJsonFile(`rewrite_patch_${suffix}.json`, payload); +} + +async function runCheckpointAwareResimulate() { + if (!authorState.activeDraftVersionId) { + authorNotice("先选择一个 draft。"); + return; + } + const checkpoint = getSimulationDiffCheckpoint(); + if (!checkpoint.available) { + authorNotice("当前没有可用的 simulation diff checkpoint。"); + return; + } + if (!checkpoint.auto_resimulate_suggested && checkpoint.suggested_action !== "simulate_draft") { + authorNotice("当前 checkpoint 不建议自动重跑 simulation。"); + return; + } + try { + await simulateDraftVersion(authorState.activeDraftVersionId); + } catch (error) { + authorNotice(formatAuthorApiErrorMessage(error, "Run Suggested Re-simulate 失败,请稍后再试。"), "error"); + } +} + +function splitSelectedTaskPromiseTargets() { + if (!dom.authorTaskPromiseTargets) return; + const targets = splitPromiseTargetList(dom.authorTaskPromiseTargets.value || ""); + dom.authorTaskPromiseTargets.value = targets.join("\n"); +} + +function mergeObservedPromisesIntoTargets() { + const linking = getChapterTaskSimulationLinking(); + const selectedLink = + (linking.task_links || []).find((item) => item.chapter_task_id === authorState.selectedAuthorTaskId) || + (linking.task_links || []).find((item) => item.arc_id === authorState.selectedAuthorArcId) || + null; + if (!selectedLink || !dom.authorTaskPromiseTargets) { + authorNotice("当前没有可合并的 observed promises。"); + return; + } + const merged = Array.from( + new Set([ + ...splitPromiseTargetList(dom.authorTaskPromiseTargets.value || ""), + ...((selectedLink.mapped_promises || []).map((item) => String(item.promise_id || "")).filter(Boolean)), + ]) + ); + dom.authorTaskPromiseTargets.value = merged.join("\n"); +} + +function applySelectedTaskRewritePrefill() { + const linking = getChapterTaskSimulationLinking(); + const selectedLink = + (linking.task_links || []).find((item) => item.chapter_task_id === authorState.selectedAuthorTaskId) || + (linking.task_links || []).find((item) => item.arc_id === authorState.selectedAuthorArcId) || + null; + if (!selectedLink?.rewrite_workflow?.available) { + authorNotice("当前 task 还没有可应用的 rewrite prefill。"); + return; + } + const workflow = selectedLink.rewrite_workflow; + if (dom.authorTaskObjective) { + dom.authorTaskObjective.value = workflow.suggested_task_objective || dom.authorTaskObjective.value || ""; + } + if (dom.authorTaskPromiseTargets) { + dom.authorTaskPromiseTargets.value = (workflow.suggested_promise_targets || []).join("\n"); + } + if (dom.authorTaskBulkState) { + dom.authorTaskBulkState.value = workflow.suggested_override_state || ""; + } + if (dom.authorTaskBulkIssues) { + dom.authorTaskBulkIssues.value = (workflow.issue_scope || []).join("\n"); + } + if (dom.authorTaskBulkNotes) { + dom.authorTaskBulkNotes.value = workflow.suggested_bulk_notes || ""; + } + if (workflow.rewrite_target_chapter_index) { + jumpToAuthorChapter(workflow.rewrite_target_chapter_index, "compare"); + } else { + focusAuthorPanel("longform"); + } +} + +async function bulkApplyTaskToSimulation() { + if (!authorState.activeDraftVersionId) { + authorNotice("先选择一个 draft。"); + return; + } + const linking = getChapterTaskSimulationLinking(); + const selectedLink = + (linking.task_links || []).find((item) => item.chapter_task_id === authorState.selectedAuthorTaskId) || + (linking.task_links || []).find((item) => item.arc_id === authorState.selectedAuthorArcId) || + null; + if (!selectedLink) { + authorNotice("当前没有可批量应用的 task linking。"); + return; + } + const chapterIndices = Array.from( + new Set( + (selectedLink.compare_chapters || []) + .map((item) => Number(item.chapter_index || 0)) + .concat((selectedLink.linked_chapters || []).map((item) => Number(item.chapter_index || 0))) + .filter((item) => item > 0) + ) + ); + if (!chapterIndices.length) { + authorNotice("当前 task 还没有 linked chapters。"); + return; + } + try { + await api(`/v1/author/drafts/${authorState.activeDraftVersionId}/task-bulk-apply`, { + method: "POST", + body: JSON.stringify({ + account_id: dom.authorAccountId?.value.trim() || "web_author", + chapter_indices: chapterIndices, + override_state: dom.authorTaskBulkState?.value || "", + notes: dom.authorTaskBulkNotes?.value || "", + issue_scope: parseMultilineList(dom.authorTaskBulkIssues?.value || ""), + chapter_task_id: selectedLink.chapter_task_id, + arc_id: selectedLink.arc_id, + volume_id: selectedLink.volume_id, + }), + }); + if (chapterIndices[0]) { + authorState.selectedAuthorContinuityChapterIndex = chapterIndices[0]; + } + authorState.activeDraftDetail = await api(`/v1/author/drafts/${authorState.activeDraftVersionId}`); + await refreshAuthorSurface(); + focusAuthorPanel("compare"); + } catch (error) { + const detail = parseErrorDetail(error); + await refreshAuthorSurface(); + if (detail?.code === "author_entitlement_required") { + alertAuthorGating(detail, "批量应用 Task -> Simulation"); + return; + } + authorNotice(formatAuthorApiErrorMessage(error, "批量应用 Task -> Simulation 失败,请稍后再试。"), "error"); + } +} + +function renderContinuityDiffWorkbench() { + if (!dom.authorContinuityDiff) return; + clearNode(dom.authorContinuityDiff); + const workbench = getContinuityDiffWorkbench(); + const overrideWorkbench = getContinuityOverrideWorkbench(); + if (!workbench.available) { + clearNode(dom.authorContinuityDiff, "完成 simulation 或 revision compare 后,这里会显示 continuity drift 和 before-after diff。"); + if (dom.authorContinuityChapterSelect) dom.authorContinuityChapterSelect.innerHTML = ""; + if (dom.authorContinuityOverrideState) dom.authorContinuityOverrideState.value = ""; + if (dom.authorContinuityIssueScope) dom.authorContinuityIssueScope.value = ""; + if (dom.authorContinuityOverrideNotes) dom.authorContinuityOverrideNotes.value = ""; + return; + } + const candidateChapters = Array.isArray(overrideWorkbench.candidate_chapters) ? overrideWorkbench.candidate_chapters : []; + if (!authorState.selectedAuthorContinuityChapterIndex || !candidateChapters.some((item) => Number(item.chapter_index || 0) === Number(authorState.selectedAuthorContinuityChapterIndex || 0))) { + authorState.selectedAuthorContinuityChapterIndex = candidateChapters[0]?.chapter_index || (workbench.top_changed_chapters || [])[0]?.chapter_index || null; + } + const selectedContinuity = + candidateChapters.find((item) => Number(item.chapter_index || 0) === Number(authorState.selectedAuthorContinuityChapterIndex || 0)) || + (workbench.top_changed_chapters || []).find((item) => Number(item.chapter_index || 0) === Number(authorState.selectedAuthorContinuityChapterIndex || 0)) || + candidateChapters[0] || + (workbench.top_changed_chapters || [])[0]; + if (dom.authorContinuityChapterSelect) { + dom.authorContinuityChapterSelect.innerHTML = candidateChapters + .map((item) => ``) + .join(""); + dom.authorContinuityChapterSelect.value = selectedContinuity?.chapter_index ? String(selectedContinuity.chapter_index) : ""; + } + if (dom.authorContinuityOverrideState) { + dom.authorContinuityOverrideState.innerHTML = [``, ...(overrideWorkbench.state_options || []).map((item) => ``)].join(""); + dom.authorContinuityOverrideState.value = selectedContinuity?.override_state || ""; + } + if (dom.authorContinuityIssueScope) { + dom.authorContinuityIssueScope.value = (selectedContinuity?.override_issue_scope || selectedContinuity?.issue_codes || []).join("\n"); + } + if (dom.authorContinuityOverrideNotes) { + dom.authorContinuityOverrideNotes.value = selectedContinuity?.override_notes || ""; + } + dom.authorContinuityDiff.appendChild( + createListCard({ + title: "Continuity Summary", + score: workbench.simulation_freshness?.status || "-", + body: + `drifting characters ${(workbench.drifting_characters || []).length}\n` + + `causal breaks ${(workbench.causal_breaks || []).length}\n` + + `promise risks ${(workbench.promise_risks || []).length}\n` + + `changed chapters ${(workbench.top_changed_chapters || []).length}\n` + + `override count ${overrideWorkbench.override_count ?? 0}\n` + + `next actions ${(workbench.next_actions || []).join(" / ") || "-"}` + }) + ); + if (selectedContinuity) { + dom.authorContinuityDiff.appendChild( + createListCard({ + title: "Selected Continuity Chapter", + score: selectedContinuity.override_state || selectedContinuity.source || "-", + body: + `${selectedContinuity.chapter_index}. ${selectedContinuity.chapter_title || selectedContinuity.after_title || selectedContinuity.before_title || "-"}\n` + + `scene ${selectedContinuity.scene_function || "-"} · issues ${(selectedContinuity.issue_codes || []).join("/") || "-"}\n` + + `override scope ${(selectedContinuity.override_issue_scope || []).join("/") || "-"}\n` + + `notes ${selectedContinuity.override_notes || "-"}` + }) + ); + } + dom.authorContinuityDiff.appendChild( + createListCard({ + title: "Top Changed Chapters", + score: `${(workbench.top_changed_chapters || []).length} chapters`, + body: + `${(workbench.top_changed_chapters || []).map((item) => `${selectedAuthorCompareMarker(item.chapter_index)}${item.chapter_index}. ${item.before_title || "-"} -> ${item.after_title || "-"}\nscore ${Number(item.overall_score_delta || 0).toFixed(3)} · issues + ${(item.issue_codes_added || []).join("/") || "-"} · - ${(item.issue_codes_removed || []).join("/") || "-"}\noverride ${item.override_state || "-"} · notes ${item.override_notes || "-"}`).join("\n\n") || "-"}` + }) + ); + if ((workbench.top_changed_chapters || [])[0]?.chapter_index) { + const actions = document.createElement("div"); + actions.className = "composer-actions"; + const jumpButton = document.createElement("button"); + jumpButton.className = "ghost-action"; + jumpButton.textContent = "跳到首个 Diff 章节"; + jumpButton.addEventListener("click", () => { + jumpToAuthorChapter(workbench.top_changed_chapters[0].chapter_index, "compare"); + }); + actions.appendChild(jumpButton); + const button = document.createElement("button"); + button.className = "ghost-action"; + button.textContent = "评论首个 Diff 章节"; + button.addEventListener("click", () => { + prefillAuthorCommentAnchor("simulation", String(workbench.top_changed_chapters[0].chapter_index)); + }); + actions.appendChild(button); + dom.authorContinuityDiff.appendChild(actions); + } + if ((workbench.drifting_characters || []).length) { + dom.authorContinuityDiff.appendChild( + createListCard({ + title: "Character Drift", + score: `${workbench.drifting_characters.length} hits`, + body: + `${(workbench.drifting_characters || []).map((item) => `${selectedAuthorCompareMarker(item.chapter_index)}${item.chapter_index}. ${item.chapter_title || "-"} · ${item.scene_function || "-"}\nissues ${(item.issue_codes || []).join("/") || "-"} · override ${item.override_state || "-"}`).join("\n\n")}` + }) + ); + } + if ((workbench.causal_breaks || []).length || (workbench.promise_risks || []).length) { + dom.authorContinuityDiff.appendChild( + createListCard({ + title: "Causal / Promise Risks", + score: `${(workbench.causal_breaks || []).length + (workbench.promise_risks || []).length} items`, + body: + `${(workbench.causal_breaks || []).map((item) => `${selectedAuthorCompareMarker(item.chapter_index)}causal ${item.chapter_index}. ${item.chapter_title || "-"} · ${(item.issue_codes || []).join("/") || "-"} · ${item.override_state || "-"}`).join("\n") || "-"}` + + `\n\n` + + `${(workbench.promise_risks || []).map((item) => `${selectedAuthorCompareMarker(item.chapter_index)}promise ${item.chapter_index}. ${item.chapter_title || "-"} · ${(item.issue_codes || []).join("/") || "-"} · ${item.override_state || "-"}`).join("\n") || "-"}` + }) + ); + } +} + +function parseMultilineList(value) { + return String(value || "") + .split("\n") + .map((item) => item.trim()) + .filter(Boolean); +} + +function splitPromiseTargetList(value) { + const seen = new Set(); + const results = []; + String(value || "") + .split("\n") + .flatMap((line) => line.split(/[;,,/+|]+/)) + .map((item) => item.trim()) + .filter(Boolean) + .forEach((item) => { + if (seen.has(item)) return; + seen.add(item); + results.push(item); + }); + return results; +} + +const LONGFORM_DUTY_CYCLE = [ + "advance_plot", + "advance_relationship", + "expand_world", + "resolve_promise", + "pace_breath", + "deliver_climax", +]; + +function normalizeLongformArcTasks(arc, chapterBudgetPolicy) { + const targetChapters = Math.max(1, Number(arc.target_chapters || 1)); + const existing = Array.isArray(arc.chapter_tasks) ? arc.chapter_tasks : []; + const baseWords = Math.max(500, Number(chapterBudgetPolicy?.default_target_words || 2000)); + const revealBudget = Math.max(0, Number(chapterBudgetPolicy?.default_reveal_budget || 1)); + const tasks = []; + for (let index = 0; index < targetChapters; index += 1) { + const previous = existing[index] || {}; + const dutyType = + previous.duty_type || + LONGFORM_DUTY_CYCLE[index % LONGFORM_DUTY_CYCLE.length]; + tasks.push({ + chapter_task_id: previous.chapter_task_id || `${arc.arc_id}::task_${index + 1}`, + objective: previous.objective || `以 ${dutyType} 为主职责推进当前弧线。`, + duty_type: dutyType, + target_words: Math.max(500, Number(previous.target_words || baseWords)), + reveal_budget: Math.max(0, Number(previous.reveal_budget ?? revealBudget)), + promise_actions: Array.isArray(previous.promise_actions) && previous.promise_actions.length + ? previous.promise_actions + : ["maintain_continuity"], + promise_targets: Array.isArray(previous.promise_targets) ? previous.promise_targets : [], + allow_terminal: Boolean(previous.allow_terminal), + notes: previous.notes || "author_workbench_normalized_task", + }); + } + return tasks; +} + +function formatMultilineList(values) { + return (values || []).join("\n"); +} + +function parseLabelMap(value) { + const result = {}; + for (const rawLine of String(value || "").split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + const separatorIndex = line.includes(":") ? line.indexOf(":") : line.indexOf("="); + if (separatorIndex <= 0) continue; + const key = line.slice(0, separatorIndex).trim(); + const label = line.slice(separatorIndex + 1).trim(); + if (key && label) { + result[key] = label; + } + } + return result; +} + +function formatLabelMap(value) { + return Object.entries(value || {}) + .map(([key, label]) => `${key}: ${label}`) + .join("\n"); +} + +function parseSceneHooks(value) { + const hooks = {}; + for (const rawLine of String(value || "").split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + const separatorIndex = line.includes(":") ? line.indexOf(":") : line.indexOf("="); + if (separatorIndex <= 0) continue; + const sceneFunction = line.slice(0, separatorIndex).trim(); + const hook = line.slice(separatorIndex + 1).trim(); + if (!sceneFunction || !hook) continue; + hooks[sceneFunction] = hooks[sceneFunction] || []; + hooks[sceneFunction].push(hook); + } + return hooks; +} + +function formatSceneHooks(value) { + const lines = []; + Object.entries(value || {}).forEach(([sceneFunction, hooks]) => { + (hooks || []).forEach((hook) => { + if (hook) { + lines.push(`${sceneFunction}: ${hook}`); + } + }); + }); + return lines.join("\n"); +} + +function renderStylePacingHookControls() { + const worldpack = getActiveDraftWorldpack() || {}; + const stylePack = worldpack.narrative_style_pack || {}; + const dialoguePolicy = worldpack.dialogue_realism_policy || stylePack.dialogue || {}; + const sceneContracts = worldpack.scene_realization_contracts || {}; + const defaultSceneContract = Object.values(sceneContracts)[0] || stylePack.scene_realization || {}; + const thematicLabels = stylePack.thematic_axis_labels || {}; + + if (dom.authorStyleLexicon) { + dom.authorStyleLexicon.value = formatMultilineList(stylePack.tonal_lexicon || []); + } + if (dom.authorThemeLabels) { + dom.authorThemeLabels.value = formatLabelMap(thematicLabels); + } + if (dom.authorHookTemplates) { + dom.authorHookTemplates.value = formatMultilineList(stylePack.hook_templates || []); + } + if (dom.authorPacingRequireTurnTaking) { + dom.authorPacingRequireTurnTaking.checked = Boolean(dialoguePolicy.require_turn_taking ?? true); + } + if (dom.authorPacingRequireCounterReaction) { + dom.authorPacingRequireCounterReaction.checked = Boolean(dialoguePolicy.require_counter_reaction ?? true); + } + if (dom.authorPacingMinTurns) { + dom.authorPacingMinTurns.value = String(Number(dialoguePolicy.min_turns || 2)); + } + if (dom.authorPacingMaxTurns) { + dom.authorPacingMaxTurns.value = String(Number(dialoguePolicy.max_turns || 3)); + } + if (dom.authorPacingMinimumExchanges) { + dom.authorPacingMinimumExchanges.value = String(Number(dialoguePolicy.minimum_exchanges || 1)); + } + if (dom.authorPacingTurnPattern) { + dom.authorPacingTurnPattern.value = formatMultilineList(dialoguePolicy.turn_pattern || ["speaker", "reaction", "reply"]); + } + if (dom.authorSceneHooks) { + dom.authorSceneHooks.value = formatSceneHooks(defaultSceneContract.scene_hooks || {}); + } + if (dom.authorStyleSummary) { + appendAuthorCardGrid(dom.authorStyleSummary, [ + createAuthorSummaryCard({ + title: "风格窗口", + score: `${(stylePack.tonal_lexicon || []).length} 词`, + body: + `tone ${(stylePack.tonal_lexicon || []).slice(0, 3).join(" / ") || "-"}\n` + + `hooks ${(stylePack.hook_templates || []).slice(0, 2).join(" / ") || "-"}\n` + + `turns ${dialoguePolicy.min_turns || 2}-${dialoguePolicy.max_turns || 3}\n` + + `scene hooks ${Object.keys(defaultSceneContract.scene_hooks || {}).length}`, + }), + createAuthorSummaryCard({ + title: "能力资产", + score: `${Object.keys(worldpack.voice_profiles || {}).length} voices`, + body: + `voice ${Object.keys(worldpack.voice_profiles || {}).length}\n` + + `action ${Object.keys(worldpack.emotion_action_policies || {}).length}\n` + + `sensory ${Object.keys(worldpack.sensory_grounding_policies || {}).length}\n` + + `scene ${Object.keys(worldpack.scene_realization_contracts || {}).length}`, + }), + ]); + } +} + +function applyStylePacingHookControls(worldpack) { + worldpack.narrative_style_pack = worldpack.narrative_style_pack || {}; + const stylePack = worldpack.narrative_style_pack; + const thematicLabels = parseLabelMap(dom.authorThemeLabels?.value || ""); + stylePack.tonal_lexicon = parseMultilineList(dom.authorStyleLexicon?.value || ""); + stylePack.thematic_axis_labels = thematicLabels; + stylePack.hook_templates = parseMultilineList(dom.authorHookTemplates?.value || ""); + stylePack.tag_labels = { + ...(stylePack.tag_labels || {}), + ...thematicLabels, + }; + + worldpack.dialogue_realism_policy = worldpack.dialogue_realism_policy || {}; + const minTurns = Math.max(1, Number(dom.authorPacingMinTurns?.value || 2)); + const maxTurns = Math.max(minTurns, Number(dom.authorPacingMaxTurns?.value || 3)); + worldpack.dialogue_realism_policy.require_turn_taking = Boolean(dom.authorPacingRequireTurnTaking?.checked); + worldpack.dialogue_realism_policy.require_counter_reaction = Boolean(dom.authorPacingRequireCounterReaction?.checked); + worldpack.dialogue_realism_policy.min_turns = minTurns; + worldpack.dialogue_realism_policy.max_turns = maxTurns; + worldpack.dialogue_realism_policy.minimum_exchanges = Math.max(1, Number(dom.authorPacingMinimumExchanges?.value || 1)); + worldpack.dialogue_realism_policy.turn_pattern = parseMultilineList(dom.authorPacingTurnPattern?.value || "") || ["speaker", "reaction", "reply"]; + + const defaultContractKey = Object.keys(worldpack.scene_realization_contracts || {})[0] || "default"; + worldpack.scene_realization_contracts = worldpack.scene_realization_contracts || {}; + worldpack.scene_realization_contracts[defaultContractKey] = { + ...(worldpack.scene_realization_contracts[defaultContractKey] || {}), + scene_hooks: parseSceneHooks(dom.authorSceneHooks?.value || ""), + }; +} + +function buildSimulationDiffSummary(previousReport, currentReport) { + if (!previousReport || !currentReport) return ""; + const previous = previousReport.evaluation_summary || {}; + const current = currentReport.evaluation_summary || {}; + const parts = []; + for (const key of ["pass_rate", "rewrite_rate", "block_rate"]) { + const delta = Number(current[key] || 0) - Number(previous[key] || 0); + if (delta !== 0) { + parts.push(`${key}: ${delta >= 0 ? "+" : ""}${delta.toFixed(3)}`); + } + } + return parts.join("\n"); +} + +function renderAuthorRevisionPanels() { + clearNode(dom.authorAssetDiff); + clearNode(dom.authorVersionHistory); + const revisions = getActiveRevisionHistory(); + const diffDrilldown = getDiffDrilldown(); + const revisionEntries = diffDrilldown.revisions || []; + const latestDiff = getLatestDiffSummary(); + if (!revisions.length) { + clearNode(dom.authorAssetDiff, "保存角色、场景或能力配置后,这里会显示结构化 diff 摘要。"); + clearNode(dom.authorVersionHistory, "这里会显示最近几次 revision 与对应的修改来源。"); + return; + } + + const selectedIndex = Math.max(0, Math.min(authorState.selectedAuthorRevisionIndex ?? revisions.length - 1, revisions.length - 1)); + authorState.selectedAuthorRevisionIndex = selectedIndex; + const selectedRevision = revisions[selectedIndex]; + const selectedEntry = revisionEntries[selectedIndex] || {}; + const previousEntry = selectedIndex > 0 ? revisionEntries[selectedIndex - 1] || {} : {}; + const diffPayload = selectedEntry.diff_summary || (selectedIndex === revisions.length - 1 ? latestDiff : { + changed_sections: selectedRevision.changed_sections || [], + summary_text: selectedRevision.summary || "", + character_changes: [], + scene_changes: [], + capability_changes: [], + }); + + dom.authorAssetDiff.appendChild( + createListCard({ + title: selectedRevision.label || "最近一次修改", + score: selectedRevision.source || "-", + body: + `summary: ${diffPayload.summary_text || selectedRevision.summary || "-"}\n` + + `compare: ${previousEntry.snapshot_summary || "初始版本"} -> ${selectedEntry.snapshot_summary || "-"}\n` + + `changed_sections: ${(diffPayload.changed_sections || []).join(" / ") || "-"}\n\n` + + `section counts: sections ${diffDrilldown.section_change_counts?.sections ?? 0} · characters ${diffDrilldown.section_change_counts?.characters ?? 0} · scenes ${diffDrilldown.section_change_counts?.scenes ?? 0} · capabilities ${diffDrilldown.section_change_counts?.capabilities ?? 0}\n` + + `simulation freshness: ${diffDrilldown.simulation_freshness?.status || "-"}\n` + + `recommended next: ${(diffDrilldown.recommended_next_actions || []).join(" / ") || "-"}\n\n` + + `${(diffPayload.character_changes || []).length ? `角色改动:\n${diffPayload.character_changes.map((item) => `${item.character_id}: ${(item.changed_fields || []).join(", ")}`).join("\n")}` : "角色改动: -"}\n\n` + + `${(diffPayload.scene_changes || []).length ? `场景改动:\n${diffPayload.scene_changes.map((item) => `${item.scene_id}: ${(item.changed_fields || []).join(", ")}`).join("\n")}` : "场景改动: -"}\n\n` + + `${(diffPayload.capability_changes || []).length ? `能力改动:\n${diffPayload.capability_changes.join("\n")}` : "能力改动: -"}\n\n` + + `${selectedEntry.simulation_delta && Object.keys(selectedEntry.simulation_delta).length ? `simulation_delta:\n${Object.entries(selectedEntry.simulation_delta).map(([key, value]) => `${key}: ${typeof value === "object" ? JSON.stringify(value) : value}`).join("\n")}` : "simulation_delta: -"}` + }) + ); + const diffActions = document.createElement("div"); + diffActions.className = "composer-actions"; + const commentCurrentDiff = document.createElement("button"); + commentCurrentDiff.className = "ghost-action"; + commentCurrentDiff.textContent = "评论当前 Diff"; + commentCurrentDiff.addEventListener("click", () => { + prefillAuthorCommentAnchor("draft", selectedRevision.revision_id || authorState.activeDraftVersionId || ""); + }); + diffActions.appendChild(commentCurrentDiff); + dom.authorAssetDiff.appendChild(diffActions); + + revisions.slice().reverse().forEach((revision, reverseIndex) => { + const actualIndex = revisions.length - 1 - reverseIndex; + const revisionEntry = revisionEntries[actualIndex] || {}; + const card = document.createElement("article"); + card.className = "list-card"; + if (actualIndex === selectedIndex) { + card.classList.add("is-active"); + } + const snapshot = revision.worldpack_snapshot || {}; + card.innerHTML = ` +
+

${revision.label || revision.source || "revision"}

+ ${revision.source || "-"} +
+

${formatTimestamp(revision.created_at)}\n${revision.summary || "-"}\n${revisionEntry.snapshot_summary || `${snapshot.title || snapshot.world_id || "-"} · 角色 ${(snapshot.characters || []).length || 0} · 场景 ${(snapshot.scene_blueprints || []).length || 0}`}\nchanged ${(revisionEntry.diff_summary?.changed_sections || revision.changed_sections || []).join(" / ") || "-"}

+ `; + card.addEventListener("click", () => { + authorState.selectedAuthorRevisionIndex = actualIndex; + renderAuthorRevisionPanels(); + }); + dom.authorVersionHistory.appendChild(card); + }); +} + +function renderAuthorCompare() { + clearNode(dom.authorCompare); + const detail = authorState.activeDraftDetail || {}; + const revisionCompare = detail.revision_compare || {}; + const chapterCompare = detail.before_after_chapter_compare || {}; + if (!revisionCompare.available && !chapterCompare.available) { + clearNode(dom.authorCompare, "这里会显示 revision compare 与 before-after chapter compare。"); + return; + } + if (revisionCompare.available) { + const card = createListCard({ + title: "Revision Compare", + score: `${revisionCompare.before_revision_id || "-"} -> ${revisionCompare.after_revision_id || "-"}`, + body: + `before ${revisionCompare.before_label || "-"}\nafter ${revisionCompare.after_label || "-"}\nsummary ${revisionCompare.after_summary || "-"}\nchanged ${(revisionCompare.after_diff_summary?.changed_sections || []).join(" / ") || "-"}\nsection counts before ${revisionCompare.section_counts?.before_changed_sections ?? 0} · after ${revisionCompare.section_counts?.after_changed_sections ?? 0}\nsimulation freshness ${revisionCompare.simulation_freshness?.status || "-"}\nsimulation delta ${(revisionCompare.simulation_delta && Object.keys(revisionCompare.simulation_delta).length) ? Object.entries(revisionCompare.simulation_delta).map(([key, value]) => `${key}=${typeof value === "object" ? JSON.stringify(value) : value}`).join(" / ") : "-"}` + }); + const actions = document.createElement("div"); + actions.className = "composer-actions"; + const button = document.createElement("button"); + button.className = "ghost-action"; + button.textContent = "评论当前 Diff"; + button.addEventListener("click", () => { + prefillAuthorCommentAnchor("draft", revisionCompare.after_revision_id || authorState.activeDraftVersionId || ""); + }); + actions.appendChild(button); + card.appendChild(actions); + dom.authorCompare.appendChild(card); + } + if (chapterCompare.available) { + const topChanged = chapterCompare.top_changed_chapters || []; + const selectedCompare = + (chapterCompare.chapter_compare_map || {})[String(authorState.selectedAuthorContinuityChapterIndex || "")] || null; + const card = createListCard({ + title: "Before / After Chapter Compare", + score: `${topChanged.length} 章`, + body: + `${topChanged.map((item) => `${selectedAuthorCompareMarker(item.chapter_index)}${item.chapter_index}. ${item.before_title || "-"} -> ${item.after_title || "-"}\n${item.before_decision || "-"} -> ${item.after_decision || "-"} · score delta ${Number(item.overall_score_delta || 0).toFixed(3)}\nissues + ${(item.issue_codes_added || []).join("/") || "-"} · - ${(item.issue_codes_removed || []).join("/") || "-"}\nsignals ${(item.signal_deltas || {}) ? Object.entries(item.signal_deltas).map(([key, value]) => `${key}=${Number(value || 0).toFixed(3)}`).join(" / ") : "-"}\nBEFORE: ${item.before_excerpt || "-"}\nAFTER: ${item.after_excerpt || "-"}`).join("\n\n") || "-"}` + }); + const actions = document.createElement("div"); + actions.className = "composer-actions"; + const firstTarget = topChanged[0]; + if (firstTarget) { + const jumpButton = document.createElement("button"); + jumpButton.className = "ghost-action"; + jumpButton.textContent = "跳到首个章节对照"; + jumpButton.addEventListener("click", () => { + jumpToAuthorChapter(firstTarget.chapter_index, "compare"); + }); + actions.appendChild(jumpButton); + const button = document.createElement("button"); + button.className = "ghost-action"; + button.textContent = "评论首个章节对照"; + button.addEventListener("click", () => { + prefillAuthorCommentAnchor("simulation", String(firstTarget.chapter_index)); + }); + actions.appendChild(button); + } + card.appendChild(actions); + dom.authorCompare.appendChild(card); + if (selectedCompare) { + dom.authorCompare.appendChild( + createListCard({ + title: "Selected Chapter Compare", + score: `#${selectedCompare.chapter_index}`, + body: + `${selectedCompare.before_title || "-"} -> ${selectedCompare.after_title || "-"}\n` + + `${selectedCompare.before_decision || "-"} -> ${selectedCompare.after_decision || "-"} · score delta ${Number(selectedCompare.overall_score_delta || 0).toFixed(3)}\n` + + `issues + ${(selectedCompare.issue_codes_added || []).join("/") || "-"} · - ${(selectedCompare.issue_codes_removed || []).join("/") || "-"}\n` + + `signals ${(selectedCompare.signal_deltas || {}) ? Object.entries(selectedCompare.signal_deltas).map(([key, value]) => `${key}=${Number(value || 0).toFixed(3)}`).join(" / ") : "-"}\n\n` + + `BEFORE:\n${selectedCompare.before_excerpt || "-"}\n\nAFTER:\n${selectedCompare.after_excerpt || "-"}` + }) + ); + } + } +} + +function renderAuthorCollaboration() { + clearNode(dom.authorCollaboration); + clearNode(dom.authorReviewerInbox); + clearNode(dom.authorNotificationPreferences); + authorState.authorReviewerInboxVisibleNotificationIds = []; + const summary = authorState.authorCollaborationSummary || {}; + if (!authorState.activeDraftVersionId) { + clearNode(dom.authorCollaboration, "这里会显示 anchored comments、blocking threads 与审批状态。"); + clearNode(dom.authorReviewerInbox, "这里会显示 reviewer inbox、notifications 与待处理 approval。"); + return; + } + const approval = summary.approval_summary || {}; + const notificationSummary = summary.notification_summary || {}; + const draftWatcherSummary = summary.draft_watcher_summary || {}; + const canReview = authorSessionCanReview(); + const reviewerId = activeAuthorReviewerId(); + const inbox = authorState.authorReviewerInbox || {}; + if (dom.authorReviewerGateNote) { + dom.authorReviewerGateNote.textContent = canReview + ? `当前账号具备审阅权限,${reviewerId || "当前 reviewer"} 的待审内容会在下面展开。` + : "拥有审阅者权限的账号登录后,这里会展开可审阅内容与 reviewer inbox。"; + } + const threads = summary.threads || []; + const selectedThread = + threads.find((item) => item.thread_id === authorState.selectedAuthorThreadId) || + threads[0] || + null; + const card = createListCard({ + title: "Collaboration Summary", + score: summary.recommended_next_action || "-", + body: + `open ${summary.open_thread_count ?? 0} · blocking ${summary.blocking_thread_count ?? 0}\napproval ${approval.latest_status || "-"}\nnotifications unread ${notificationSummary.unread_count ?? 0} / total ${notificationSummary.notification_count ?? 0}\nqueue ${(summary.queue_summary?.status_counts && Object.entries(summary.queue_summary.status_counts).map(([key, value]) => `${key}=${value}`).join(" / ")) || "-"}\nthreads by anchor ${(summary.threads_by_anchor || []).map((item) => `${item.anchor_type}:${item.anchor_key}=${item.thread_count}`).join(" / ") || "-"}` + }); + dom.authorCollaboration.appendChild(card); + if ((summary.assignee_queues || []).length) { + dom.authorCollaboration.appendChild( + createListCard({ + title: "Assignee Queues", + score: `${summary.assignee_queues.length} queues`, + body: + `${summary.assignee_queues.map((item) => `${item.assignee_id} · open ${item.open_count} · blocking ${item.blocking_count} · total ${item.thread_count}`).join("\n") || "-"}` + }) + ); + } + if ((draftWatcherSummary.watcher_ids || []).length) { + dom.authorCollaboration.appendChild( + createListCard({ + title: "Draft Watchers", + score: `${draftWatcherSummary.watcher_count ?? 0} watchers`, + body: + `explicit ${draftWatcherSummary.explicit_watcher_count ?? 0}\nwatchers ${(draftWatcherSummary.watcher_ids || []).join(" / ") || "-"}` + }) + ); + } + if (selectedThread) { + const selectedCard = document.createElement("article"); + selectedCard.className = "list-card is-active"; + selectedCard.innerHTML = ` +
+

Thread Detail · ${selectedThread.anchor_type}:${selectedThread.anchor_key}

+ ${selectedThread.status || "-"} / ${selectedThread.severity || "-"} +
+

thread ${selectedThread.thread_id}\nassignee ${selectedThread.assignee_id || "-"} · created_by ${selectedThread.created_by || "-"}\nwatchers ${(selectedThread.watcher_ids || []).join(" / ") || "-"}\nnotifications ${selectedThread.unread_notification_count ?? 0} unread / ${selectedThread.notification_count ?? 0}\nlatest ${selectedThread.latest_message_actor_id || "-"} · ${selectedThread.latest_message_at || "-"}

+ `; + const selectedActions = document.createElement("div"); + selectedActions.className = "composer-actions"; + const toggleWatchButton = document.createElement("button"); + toggleWatchButton.className = "ghost-action"; + const currentActorId = activeAuthorActorId(); + const isWatching = (selectedThread.watcher_ids || []).includes(currentActorId); + toggleWatchButton.textContent = isWatching ? "Unwatch" : "Watch"; + toggleWatchButton.addEventListener("click", async (event) => { + event.stopPropagation(); + if (isWatching) { + await removeAuthorThreadWatcher(selectedThread.thread_id, currentActorId); + } else { + await addAuthorThreadWatcher(selectedThread.thread_id, currentActorId); + } + }); + selectedActions.appendChild(toggleWatchButton); + if (canReview && reviewerId && selectedThread.assignee_id !== reviewerId) { + const assignReviewerButton = document.createElement("button"); + assignReviewerButton.className = "ghost-action"; + assignReviewerButton.textContent = "Assign Reviewer"; + assignReviewerButton.addEventListener("click", async (event) => { + event.stopPropagation(); + await updateAuthorThreadStatusInline(selectedThread.thread_id, selectedThread.status || "open", { + assigneeId: reviewerId, + body: `指派给 ${reviewerId}。`, + }); + }); + selectedActions.appendChild(assignReviewerButton); + } + const toggleStatusButton = document.createElement("button"); + toggleStatusButton.className = "ghost-action"; + toggleStatusButton.textContent = selectedThread.status === "open" ? "Resolve" : "Reopen"; + toggleStatusButton.addEventListener("click", async (event) => { + event.stopPropagation(); + await updateAuthorThreadStatusInline(selectedThread.thread_id, selectedThread.status === "open" ? "resolved" : "open", { + assigneeId: selectedThread.assignee_id || undefined, + body: selectedThread.status === "open" ? "Inline thread detail 标记为已处理。" : "Inline thread detail 重新打开。", + }); + }); + selectedActions.appendChild(toggleStatusButton); + selectedCard.appendChild(selectedActions); + + (selectedThread.messages || []).forEach((message) => { + selectedCard.appendChild( + createListCard({ + title: `${message.actor_id || "-"} · ${message.actor_role || "-"}`, + score: message.created_at || "-", + body: + `${message.body || "-"}\nmentions ${(message.mentioned_actor_ids || []).join(" / ") || "-"}` + }) + ); + }); + + const replyBox = document.createElement("div"); + replyBox.className = "list-card"; + const replyTitle = document.createElement("div"); + replyTitle.className = "list-card-head"; + replyTitle.innerHTML = `

Reply Inline

${activeAuthorActorId()} / ${activeAuthorActorRole()}`; + replyBox.appendChild(replyTitle); + const replyInput = document.createElement("textarea"); + replyInput.rows = 4; + replyInput.placeholder = "输入 thread 回复,可继续用 @mention。"; + replyInput.value = authorState.authorInlineReplyDraft || ""; + replyInput.addEventListener("input", () => { + authorState.authorInlineReplyDraft = replyInput.value; + }); + replyBox.appendChild(replyInput); + const replyActions = document.createElement("div"); + replyActions.className = "composer-actions"; + const replyButton = document.createElement("button"); + replyButton.className = "ghost-action"; + replyButton.textContent = "Send Reply"; + replyButton.addEventListener("click", async () => { + await replyToSelectedAuthorThread(selectedThread.thread_id); + }); + replyActions.appendChild(replyButton); + replyBox.appendChild(replyActions); + selectedCard.appendChild(replyBox); + dom.authorCollaboration.appendChild(selectedCard); + } + if ((notificationSummary.latest_notifications || []).length) { + dom.authorCollaboration.appendChild( + createListCard({ + title: "Latest Notifications", + score: `${notificationSummary.unread_count ?? 0} unread`, + body: + `${(notificationSummary.latest_notifications || []).map((item) => `${item.recipient_id} · ${item.notification_type} · ${item.status}\n${item.title}\n${item.body || "-"}`).join("\n\n") || "-"}` + }) + ); + } + threads.slice(0, 8).forEach((thread) => { + const threadCard = createListCard({ + title: `${thread.anchor_type}:${thread.anchor_key}`, + score: `${thread.status || "-"} / ${thread.severity || "-"}`, + body: + `assignee ${thread.assignee_id || "-"} · created_by ${thread.created_by || "-"}\nparticipants ${(thread.participant_ids || []).join(" / ") || "-"}\nmentions ${(thread.mentioned_actor_ids || []).join(" / ") || "-"}\nnotifications ${thread.unread_notification_count ?? 0} unread / ${thread.notification_count ?? 0}\nlatest ${thread.latest_message_preview || "-"}` + , + active: authorState.selectedAuthorThreadId === thread.thread_id + }); + threadCard.addEventListener("click", async () => { + await selectAuthorThread(thread.thread_id, thread.world_version_id); + }); + const actions = document.createElement("div"); + actions.className = "composer-actions"; + const focusButton = document.createElement("button"); + focusButton.className = "ghost-action"; + focusButton.textContent = "定位 Anchor"; + focusButton.addEventListener("click", (event) => { + event.stopPropagation(); + prefillAuthorCommentAnchor(thread.anchor_type, thread.anchor_key); + }); + actions.appendChild(focusButton); + if (canReview && reviewerId && thread.assignee_id !== reviewerId) { + const assignButton = document.createElement("button"); + assignButton.className = "ghost-action"; + assignButton.textContent = "指派给 Reviewer"; + assignButton.addEventListener("click", async (event) => { + event.stopPropagation(); + await updateAuthorThreadStatusInline(thread.thread_id, thread.status || "open", { + assigneeId: reviewerId, + body: `指派给 ${reviewerId}。`, + }); + }); + actions.appendChild(assignButton); + } + const statusButton = document.createElement("button"); + statusButton.className = "ghost-action"; + statusButton.textContent = thread.status === "open" ? "标记 Resolved" : "重新打开"; + statusButton.addEventListener("click", async (event) => { + event.stopPropagation(); + await updateAuthorThreadStatusInline(thread.thread_id, thread.status === "open" ? "resolved" : "open", { + assigneeId: thread.assignee_id || undefined, + body: thread.status === "open" ? "Reviewer inbox 已处理。" : "重新打开继续跟进。", + }); + }); + actions.appendChild(statusButton); + threadCard.appendChild(actions); + dom.authorCollaboration.appendChild(threadCard); + }); + + if (!canReview || !reviewerId) { + clearNode(dom.authorReviewerInbox, "当前账号没有审阅权限;普通用户只保留创作与送审发起路径。"); + return; + } + + if (dom.authorLoadMoreReviewerInbox) { + dom.authorLoadMoreReviewerInbox.disabled = !authorState.authorReviewerInboxHasMore; + dom.authorLoadMoreReviewerInbox.textContent = authorState.authorReviewerInboxHasMore ? "Load More" : "No More Results"; + } + + dom.authorReviewerInbox.appendChild( + createListCard({ + title: "Reviewer Inbox Summary", + score: inbox.recommended_next_action || "-", + body: + `reviewer ${reviewerId}\nassigned ${inbox.queue_summary?.assigned_open_thread_count ?? 0} · blocking ${inbox.queue_summary?.blocking_assigned_thread_count ?? 0}\npending approvals ${inbox.queue_summary?.pending_approval_count ?? 0}\nunread notifications ${inbox.queue_summary?.unread_notification_count ?? 0}\nreturned ${inbox.returned_count ?? (inbox.notifications || []).length} · more ${inbox.has_more ? "yes" : "no"}\nstatus ${(inbox.queue_summary?.status_counts && Object.entries(inbox.queue_summary.status_counts).map(([key, value]) => `${key}=${value}`).join(" / ")) || "-"}\ntypes ${(inbox.queue_summary?.notification_type_counts && Object.entries(inbox.queue_summary.notification_type_counts).map(([key, value]) => `${key}=${value}`).join(" / ")) || "-"}` + }) + ); + + if ((inbox.world_version_queues || []).length) { + dom.authorReviewerInbox.appendChild( + createListCard({ + title: "Inbox by Draft", + score: `${inbox.world_version_queues.length} drafts`, + body: + `${(inbox.world_version_queues || []).map((item) => `${item.world_version_id} · unread ${item.unread_count} · total ${item.notification_count}`).join("\n") || "-"}` + }) + ); + } + + (inbox.pending_approvals || []).slice(0, 4).forEach((approvalItem) => { + const approvalCard = createListCard({ + title: `Approval ${approvalItem.world_version_id}`, + score: approvalItem.status || "requested", + body: + `reviewer ${approvalItem.reviewer_id || "-"}\nrevision ${approvalItem.revision_id || "-"}\nreason ${approvalItem.reason || "-"}` + }); + const actions = document.createElement("div"); + actions.className = "composer-actions"; + const approveButton = document.createElement("button"); + approveButton.className = "ghost-action"; + approveButton.textContent = "批准"; + approveButton.addEventListener("click", async () => { + await decideAuthorApprovalForWorld(approvalItem.world_version_id, "approved", reviewerId, "Reviewer inbox 快速批准。"); + }); + actions.appendChild(approveButton); + const changesButton = document.createElement("button"); + changesButton.className = "ghost-action"; + changesButton.textContent = "要求修改"; + changesButton.addEventListener("click", async () => { + await decideAuthorApprovalForWorld(approvalItem.world_version_id, "changes_requested", reviewerId, "Reviewer inbox 要求修改。"); + }); + actions.appendChild(changesButton); + approvalCard.appendChild(actions); + dom.authorReviewerInbox.appendChild(approvalCard); + }); + + const visibleNotifications = (inbox.notifications || []).slice(0, 8); + authorState.authorReviewerInboxVisibleNotificationIds = visibleNotifications.map((item) => item.notification_id).filter(Boolean); + visibleNotifications.forEach((notification) => { + const notificationCard = createListCard({ + title: notification.title || notification.notification_type || "Notification", + score: `${notification.status || "-"} / ${notification.recipient_role || "-"}`, + body: + `type ${notification.notification_type || "-"} · world ${notification.world_version_id || "-"}\nactor ${notification.actor_id || "-"} · recipient ${notification.recipient_id || "-"}\nanchor ${notification.anchor_type || "-"}:${notification.anchor_key || "-"}\n${notification.body || "-"}` + }); + notificationCard.addEventListener("click", async () => { + if (notification.thread_id) { + await selectAuthorThread(notification.thread_id, notification.world_version_id || ""); + } + }); + const actions = document.createElement("div"); + actions.className = "composer-actions"; + const readButton = document.createElement("button"); + readButton.className = "ghost-action"; + readButton.textContent = "标记已读"; + readButton.addEventListener("click", async (event) => { + event.stopPropagation(); + await updateAuthorNotificationStatus(notification.notification_id, "read"); + }); + actions.appendChild(readButton); + const archiveButton = document.createElement("button"); + archiveButton.className = "ghost-action"; + archiveButton.textContent = "归档"; + archiveButton.addEventListener("click", async (event) => { + event.stopPropagation(); + await updateAuthorNotificationStatus(notification.notification_id, "archived"); + }); + actions.appendChild(archiveButton); + if (notification.anchor_type && notification.anchor_key) { + const focusButton = document.createElement("button"); + focusButton.className = "ghost-action"; + focusButton.textContent = "跳到线程"; + focusButton.addEventListener("click", async (event) => { + event.stopPropagation(); + if (notification.thread_id) { + await selectAuthorThread(notification.thread_id, notification.world_version_id || ""); + } + prefillAuthorCommentAnchor(notification.anchor_type, notification.anchor_key); + }); + actions.appendChild(focusButton); + } + notificationCard.appendChild(actions); + dom.authorReviewerInbox.appendChild(notificationCard); + }); + + (inbox.blocking_assigned_threads || []).slice(0, 4).forEach((thread) => { + const blockingCard = createListCard({ + title: `Blocking ${thread.anchor_type}:${thread.anchor_key}`, + score: thread.severity || "blocker", + body: + `thread ${thread.thread_id}\nlatest ${thread.latest_message_preview || "-"}\nstatus ${thread.status || "-"} · assignee ${thread.assignee_id || "-"}` + }); + blockingCard.addEventListener("click", async () => { + await selectAuthorThread(thread.thread_id, thread.world_version_id); + }); + const actions = document.createElement("div"); + actions.className = "composer-actions"; + const resolveButton = document.createElement("button"); + resolveButton.className = "ghost-action"; + resolveButton.textContent = "处理完成"; + resolveButton.addEventListener("click", async (event) => { + event.stopPropagation(); + await updateAuthorThreadStatusInline(thread.thread_id, "resolved", { + actorId: reviewerId, + assigneeId: reviewerId, + body: "Reviewer inbox 标记为已处理。", + }); + }); + actions.appendChild(resolveButton); + blockingCard.appendChild(actions); + dom.authorReviewerInbox.appendChild(blockingCard); + }); + + const preferences = authorState.authorNotificationPreferences?.preferences || []; + if (!preferences.length) { + clearNode(dom.authorNotificationPreferences, "这里会显示当前 actor 的 notification preferences。"); + } else { + dom.authorNotificationPreferences.appendChild( + createListCard({ + title: `Notification Preferences · ${authorState.authorNotificationPreferences?.actor_id || activeAuthorActorId()}`, + score: `${preferences.length} types`, + body: + `${preferences.map((item) => `${item.notification_type} · in-app ${item.in_app_enabled ? "on" : "off"} · async ${item.async_mirror_enabled ? "on" : "off"} · sink ${item.async_sink_name || "default"} · target ${item.delivery_target || "-"}${item.is_default ? " · default" : ""}`).join("\n") || "-"}` + }) + ); + } +} + +function renderAuthorDrafts() { + clearNode(dom.authorDraftList); + if (!authorState.authorDrafts.length) { + clearNode(dom.authorDraftList, "还没有 draft。先把当前世界保存为 Draft。"); + return; + } + const simulateAccess = authorState.authorAccessSnapshot?.actions?.simulate || null; + const submitAccess = authorState.authorAccessSnapshot?.actions?.submit_draft || null; + const validateAccess = authorState.authorAccessSnapshot?.actions?.validate_draft || null; + authorState.authorDrafts.forEach((draft) => { + const card = document.createElement("article"); + card.className = "list-card"; + if (draft.world_version_id === authorState.activeDraftVersionId) { + card.classList.add("is-active"); + } + card.innerHTML = ` +
+

${draft.title || draft.world_id}

+ ${draft.status} +
+

版本 ${draft.version || draft.world_version_id} · 风险 ${draft.risk_rating || "未定"}

+
+ + + +
+ `; + card.querySelector(".draft-validate").addEventListener("click", () => { + (async () => { + try { + await validateDraftVersion(draft.world_version_id); + } catch (error) { + const detail = parseErrorDetail(error); + await refreshAuthorSurface(); + if (detail?.code === "author_entitlement_required") { + alertAuthorGating(detail, "校验 Draft"); + return; + } + authorNotice(formatAuthorApiErrorMessage(error, "校验失败,请稍后再试。"), "error"); + } + })(); + }); + card.querySelector(".draft-simulate").addEventListener("click", async () => { + try { + await simulateDraftVersion(draft.world_version_id); + } catch (error) { + const detail = parseErrorDetail(error); + await refreshAuthorSurface(); + if (detail?.code === "author_entitlement_required") { + authorNotice(`当前不能模拟:${accessReasonLabel(detail.reason)}。需要 ${detail.required_display_name || tierLabel(detail.required_tier)},当前 ${detail.wallet_type || "-"} 余额 ${Number(detail.balance || 0).toFixed(0)}。`); + return; + } + authorNotice(formatAuthorApiErrorMessage(error, "模拟失败,请稍后再试。"), "error"); + } + }); + if (simulateAccess && !simulateAccess.allowed) { + const button = card.querySelector(".draft-simulate"); + button.disabled = true; + button.title = gatingHint(simulateAccess); + } + if (validateAccess && !validateAccess.allowed) { + const button = card.querySelector(".draft-validate"); + button.disabled = true; + button.title = gatingHint(validateAccess); + } + card.querySelector(".draft-submit").addEventListener("click", async () => { + try { + await submitDraftVersion(draft.world_version_id); + } catch (error) { + const detail = parseErrorDetail(error); + await refreshAuthorSurface(); + if (detail?.code === "author_entitlement_required") { + alertAuthorGating(detail, "提交送审"); + return; + } + authorNotice(formatAuthorApiErrorMessage(error, "送审失败,请稍后再试。"), "error"); + } + }); + if (submitAccess && !submitAccess.allowed) { + const button = card.querySelector(".draft-submit"); + button.disabled = true; + button.title = gatingHint(submitAccess); + } + card.addEventListener("click", async () => { + authorState.activeDraftVersionId = draft.world_version_id; + authorState.activeDraftDetail = await api(`/v1/author/drafts/${draft.world_version_id}`); + authorState.selectedAuthorRevisionIndex = null; + renderAuthorDrafts(); + renderAuthorReports(); + }); + dom.authorDraftList.appendChild(card); + }); +} + +function renderAuthorWorkflow() { + clearNode(dom.authorWorkflow); + const workflow = authorState.authorWorkflowSummary; + if (!workflow) { + clearNode(dom.authorWorkflow, "这里会显示 brief -> draft -> simulate -> revise -> submit 的当前阶段与建议动作。"); + return; + } + const primaryAction = (workflow.cta_actions || []).find((item) => item.primary) || (workflow.cta_actions || [])[0] || null; + const blockers = workflow.blockers || []; + const readiness = workflow.longform_readiness || {}; + const promiseRunway = workflow.promise_runway_summary || {}; + const card = document.createElement("article"); + card.className = "list-card author-workflow-card"; + card.innerHTML = ` +
推荐下一步
+
+
+

${authorRecommendedActionLabel(primaryAction?.action_id || workflow.recommended_action)}

+

${workflow.draft_title || "当前 Draft"} · ${authorStageLabel(workflow.stage)} · 推荐入口 ${authorRecommendedWorkspaceLabel(primaryAction?.action_id || workflow.recommended_action)} · 当前口径 ${workflow.claim_safe_band ? `${workflow.claim_safe_band}章` : "未达安全承诺"}

+
+ ${authorStageLabel(workflow.stage)} +
+
+
+ 推荐动作 + ${primaryAction?.label || workflow.recommended_action || "-"} +
+
+ 校验 + ${workflow.validation_summary?.status || "-"} +

errors ${workflow.validation_summary?.error_count ?? 0} · warnings ${workflow.validation_summary?.warning_count ?? 0}

+
+
+ 模拟 + ${workflow.simulation_summary?.latest_decision || "-"} +

pass ${formatPercent(workflow.simulation_summary?.pass_rate)} · rewrite ${formatPercent(workflow.simulation_summary?.rewrite_rate)} · block ${formatPercent(workflow.simulation_summary?.block_rate)}

+
+
+ 当前阻塞 + ${blockers.length ? `${blockers.length} 项` : "无 blocker"} +

${blockers[0]?.message || "可以继续往下一阶段推进。"}

+
+
+ 长线能力 + ${workflow.entry_mode === "structured_longform" ? "结构化长篇" : "Quick Brief"} +

目标 ${workflow.requested_target_band || "-"} · 当前支持 ${workflow.supported_target_band || "-"} · readiness ${readiness.status || "-"}

+
+
+
+ ${(workflow.stages || []).map((item) => `${item.label}`).join("") || '等待工作流'} +
+
+

Draft ${workflow.world_version_id || "-"}

+

Simulation freshness ${workflow.simulation_freshness?.status || "-"}

+

Longform readiness ${readiness.status || "-"} · 目标 ${workflow.requested_target_chapters || "-"} 章 · 结构 ${workflow.longform_structure_counts?.character_count || 0} 角色 / ${workflow.longform_structure_counts?.scene_blueprint_count || 0} 场景 / ${workflow.longform_structure_counts?.location_count || 0} 地点 / ${workflow.longform_structure_counts?.scene_family_count || 0} scene family / ${workflow.longform_structure_counts?.distinct_role_pair_count || 0} role pairs

+

Promise runway ${promiseRunway.runway_status || "-"} · open ${promiseRunway.open_count ?? 0} · overdue ${promiseRunway.overdue_count ?? 0} · chapters since new ${promiseRunway.chapters_since_last_new_promise ?? "-"}

+

下一步说明 ${primaryAction?.reason || blockers[0]?.message || "继续当前阶段的推荐动作。"}

+
+ `; + dom.authorWorkflow.appendChild(card); + if ((workflow.cta_actions || []).length) { + const actions = document.createElement("div"); + actions.className = "composer-actions author-workflow-actions"; + workflow.cta_actions.forEach((item) => { + const button = document.createElement("button"); + button.className = item.primary ? "primary-action" : "ghost-action"; + button.textContent = item.label || item.action_id; + button.disabled = item.enabled === false; + if (item.reason) { + button.title = item.reason; + } + button.addEventListener("click", async () => { + try { + await runAuthorWorkflowAction(item.action_id); + } catch (error) { + authorNotice(formatAuthorApiErrorMessage(error, "执行工作流动作失败,请稍后再试。"), "error"); + } + }); + actions.appendChild(button); + }); + dom.authorWorkflow.appendChild(actions); + } +} + +function renderAuthorGuidedFocus() { + clearNode(dom.authorGuidedFocus); + const workflow = authorState.authorWorkflowSummary; + const primaryAction = (workflow?.cta_actions || []).find((item) => item.primary) || (workflow?.cta_actions || [])[0] || null; + const titleNode = dom.authorStageTitle; + const copyNode = dom.authorStageCopy; + const currentWorkspace = shellState.authorWorkspace || "overview"; + if (currentWorkspace === "settings") { + if (titleNode) { + titleNode.textContent = "先确认身份,再决定通知和协作怎么流动"; + } + if (copyNode) { + copyNode.textContent = "Settings 只做三件事:确认当前登录态、确认通知投递、确认协作与审批的处理入口。"; + } + const preferences = authorState.authorNotificationPreferences?.preferences || []; + const collaboration = authorState.authorCollaborationSummary || {}; + const authIdentity = authorState.authorAuthSession?.identity || null; + const notificationTargets = preferences + .map((item) => item.delivery_target || item.async_sink_name || "") + .filter(Boolean) + .slice(0, 3) + .join(" / ") || "未配置外部投递"; + const authWarning = !authIdentity + ? "当前还没有登录态,作者通知和协作身份都不会稳定同步。" + : isAuthorSessionExpiringSoon(authorState.authorAuthSession?.expiresAt) + ? "当前 token 即将过期,建议尽快刷新会话。" + : ""; + const notificationWarning = !preferences.length + ? "当前没有自定义通知规则,重要更新可能只能靠手动刷新看到。" + : !preferences.some((item) => item.delivery_target || item.async_sink_name) + ? "当前没有外部投递目标,异步提醒可能收不到。" + : ""; + const collaborationWarning = + Number(collaboration.blocking_thread_count || 0) > 0 + ? `当前有 ${collaboration.blocking_thread_count} 个 blocker 线程待处理。` + : Number((collaboration.notification_summary || {}).unread_count || 0) > 0 + ? `当前有 ${(collaboration.notification_summary || {}).unread_count || 0} 条未读协作通知。` + : ""; + const settingsGrid = document.createElement("div"); + settingsGrid.className = "author-settings-summary-grid"; + const cards = [ + createAuthorSummaryCard({ + title: "登录态", + score: authIdentity?.actor_role || "未登录", + body: + `actor ${authIdentity?.actor_id || "-"}\n` + + `display ${authIdentity?.display_name || "-"}\n` + + `expires ${authorState.authorAuthSession?.expiresAt || "-"}\n` + + `next ${authIdentity ? "继续协作设置" : "先建立作者身份"}`, + warning: authWarning, + actionLabel: authIdentity ? "刷新会话" : "登录配置", + onAction: () => focusAuthorPanel("auth_settings"), + primary: true, + }), + createAuthorSummaryCard({ + title: "通知态", + score: `${preferences.length} rules`, + body: + `in-app on ${preferences.filter((item) => item.in_app_enabled).length}\n` + + `async on ${preferences.filter((item) => item.async_mirror_enabled).length}\n` + + `targets ${notificationTargets}\n` + + `next ${preferences.length ? "确认规则是否覆盖当前 Draft" : "先建一条通知规则"}`, + warning: notificationWarning, + actionLabel: "打开通知设置", + onAction: () => focusAuthorPanel("notification_settings"), + }), + createAuthorSummaryCard({ + title: "协作态", + score: collaboration.recommended_next_action || "-", + body: + `open ${collaboration.open_thread_count ?? 0} · blocking ${collaboration.blocking_thread_count ?? 0}\n` + + `approval ${(collaboration.approval_summary || {}).latest_status || "-"}\n` + + `notifications unread ${(collaboration.notification_summary || {}).unread_count ?? 0}\n` + + `next ${collaboration.recommended_next_action || "打开协作区"}`, + warning: collaborationWarning, + actionLabel: "打开协作区", + onAction: () => focusAuthorPanel("collaboration"), + }), + ]; + cards.forEach((card) => settingsGrid.appendChild(card)); + const actions = document.createElement("div"); + actions.className = "composer-actions author-guided-actions"; + [ + { label: "看登录态", panel: "auth_settings", primary: true }, + { label: "看通知态", panel: "notification_settings" }, + { label: "看协作态", panel: "collaboration" }, + ].forEach((item) => { + const button = document.createElement("button"); + button.className = item.primary ? "primary-action" : "ghost-action"; + button.textContent = item.label; + button.addEventListener("click", () => focusAuthorPanel(item.panel)); + actions.appendChild(button); + }); + dom.authorGuidedFocus.appendChild(settingsGrid); + dom.authorGuidedFocus.appendChild(actions); + return; + } + if (currentWorkspace === "simulate") { + const drilldown = getSimulationDrilldown(); + const linking = getChapterTaskSimulationLinking(); + const continuity = getContinuityDiffWorkbench(); + const firstIssueTarget = (drilldown.issue_focus_queue || [])[0]?.chapter_targets?.[0] || null; + const firstWeakChapter = (drilldown.weakest_chapters || [])[0] || null; + const taskLinks = Array.isArray(linking.task_links) ? linking.task_links : []; + const selectedTaskLink = + taskLinks.find((item) => item.chapter_task_id === authorState.selectedAuthorTaskId) || + taskLinks.find((item) => item.arc_id === authorState.selectedAuthorArcId) || + taskLinks[0] || + null; + const selectedContinuity = + (continuity.top_changed_chapters || [])[0] || + (continuity.drifting_characters || [])[0] || + (continuity.promise_risks || [])[0] || + null; + const selectedCompareChapter = selectedTaskLink?.compare_chapters?.[0] || selectedContinuity || firstWeakChapter || null; + if (titleNode) { + titleNode.textContent = "先处理最急的问题章节,再决定回哪一段继续改"; + } + if (copyNode) { + copyNode.textContent = "Simulate 页最重要的是把 issue queue 和 weakest chapters 直接连回编辑动作,而不是让作者先读完整份报告。"; + } + const card = document.createElement("article"); + card.className = "list-card author-guided-focus-card"; + card.innerHTML = ` +
Simulate 导航
+

${firstIssueTarget ? `先看第 ${firstIssueTarget.chapter_index} 章的问题章节` : "先看最弱章节与 issue queue"}

+

${firstIssueTarget ? `${firstIssueTarget.chapter_title || "-"} · ${firstIssueTarget.scene_function || "-"} / ${firstIssueTarget.decision || "-"}` : "当前没有 issue focus queue,可以先从 weakest chapters 入手。"}

+
+ Issue Queue ${(drilldown.issue_focus_queue || []).length} 项 + Weakest ${(drilldown.weakest_chapters || []).length} 章 + Chapter Breakdown ${(drilldown.chapter_breakdown || []).length} 章 +
+ `; + const actions = document.createElement("div"); + actions.className = "composer-actions author-guided-actions"; + if (firstIssueTarget) { + const jumpIssue = document.createElement("button"); + jumpIssue.className = "primary-action"; + jumpIssue.textContent = "跳到问题章节"; + jumpIssue.addEventListener("click", () => jumpToAuthorChapter(firstIssueTarget.chapter_index, "simulation")); + const commentIssue = document.createElement("button"); + commentIssue.className = "ghost-action"; + commentIssue.textContent = "评论这个问题章节"; + commentIssue.addEventListener("click", () => prefillAuthorCommentAnchor("simulation", String(firstIssueTarget.chapter_index))); + actions.appendChild(jumpIssue); + actions.appendChild(commentIssue); + } + if (firstWeakChapter) { + const jumpWeak = document.createElement("button"); + jumpWeak.className = actions.childElementCount ? "ghost-action" : "primary-action"; + jumpWeak.textContent = "查看最弱章节"; + jumpWeak.addEventListener("click", () => jumpToAuthorChapter(firstWeakChapter.chapter_index, "simulation")); + actions.appendChild(jumpWeak); + } + const openDraft = document.createElement("button"); + openDraft.className = "ghost-action"; + openDraft.textContent = "回 Draft Workbench"; + openDraft.addEventListener("click", () => focusAuthorPanel("longform")); + actions.appendChild(openDraft); + dom.authorGuidedFocus.appendChild(card); + const workGrid = document.createElement("div"); + workGrid.className = "author-simulate-work-grid"; + const taskCard = createAuthorWorkCard({ + title: "Chapter Task Work Card", + score: selectedTaskLink?.status || (selectedTaskLink ? "linked" : "未链接"), + body: + `${selectedTaskLink ? `${selectedTaskLink.chapter_task_id}\n当前对象 ${selectedTaskLink.duty_type || "-"} · ${selectedTaskLink.objective || "-"}\n当前问题 ${(selectedTaskLink.compare_summary?.issue_codes_added || []).join("/") || (selectedTaskLink.compare_summary?.issue_codes_removed || []).join("/") || "暂无明显 diff issue"}\n下一步 先回 Task Linking 或 Longform Workbench 修改任务与 promise。` : "当前还没有 chapter task linking。\n当前问题 simulation 还没把 chapter task 显式连到章节。\n下一步 先回 Longform Workbench 查看当前 arc/task。"}` + , + warning: selectedTaskLink ? "" : "当前还没有 task linking,先回 Draft 侧建立或确认 chapter task。", + active: Boolean(selectedTaskLink && authorState.selectedAuthorTaskId && selectedTaskLink.chapter_task_id === authorState.selectedAuthorTaskId), + actions: [ + ...(selectedTaskLink ? [{ + label: "打开 Task Linking", + primary: true, + onClick: () => { + authorState.selectedAuthorTaskId = selectedTaskLink.chapter_task_id; + renderAuthorReports(); + focusAuthorPanel("task_linking"); + }, + }] : []), + ...(selectedTaskLink?.linked_chapters?.[0]?.chapter_index ? [{ + label: "跳到 Task 章节", + onClick: () => { + jumpToAuthorChapter(selectedTaskLink.linked_chapters[0].chapter_index, "simulation"); + }, + }] : []), + { + label: "回 Longform Workbench", + primary: !selectedTaskLink, + onClick: () => focusAuthorPanel("longform"), + }, + ] + }); + workGrid.appendChild(taskCard); + + const continuityCard = createAuthorWorkCard({ + title: "Continuity Work Card", + score: selectedContinuity?.override_state || selectedContinuity?.source || "待检查", + body: + `${selectedContinuity ? `${selectedContinuity.chapter_index}. ${selectedContinuity.chapter_title || selectedContinuity.after_title || selectedContinuity.before_title || "-"}\n当前问题 ${(selectedContinuity.issue_codes || []).join("/") || "暂无 issue"} · override ${selectedContinuity.override_state || "-"}\n下一步 先打开 Continuity Diff,确认 override 和 issue scope。` : "当前没有 continuity drift。\n当前问题 暂无可直接处理的 continuity 项。\n下一步 可先从 top changed chapters 或 weakest chapter 进入 Compare。"}` + , + warning: selectedContinuity ? "" : "当前没有 continuity drift 命中,可从 Compare 或 Weakest Chapters 先找目标。", + active: Boolean(selectedContinuity?.chapter_index && Number(authorState.selectedAuthorContinuityChapterIndex || 0) === Number(selectedContinuity.chapter_index)), + actions: [ + ...(selectedContinuity?.chapter_index ? [{ + label: "打开 Continuity Diff", + primary: true, + onClick: () => { + authorState.selectedAuthorContinuityChapterIndex = selectedContinuity.chapter_index; + renderAuthorReports(); + focusAuthorPanel("continuity"); + }, + }, { + label: "评论这个 Diff", + onClick: () => { + prefillAuthorCommentAnchor("simulation", String(selectedContinuity.chapter_index)); + }, + }] : []), + ] + }); + workGrid.appendChild(continuityCard); + + const compareCard = createAuthorWorkCard({ + title: "Compare Work Card", + score: selectedCompareChapter?.chapter_index ? `#${selectedCompareChapter.chapter_index}` : "待选择", + body: + `${selectedCompareChapter ? `${selectedCompareChapter.chapter_index}. ${selectedCompareChapter.after_title || selectedCompareChapter.chapter_title || selectedCompareChapter.before_title || "-"}\n当前问题 ${(selectedCompareChapter.issue_codes || selectedCompareChapter.issue_codes_added || []).join("/") || "-"} · scene ${selectedCompareChapter.scene_function || "-"}\n下一步 先打开 Compare 看 before-after,再决定是否去 Review 留证据。` : "当前没有可直接对照的章节。\n当前问题 还没有 compare target。\n下一步 先从 Weakest Chapters 或 Continuity Diff 选一个章节。"}` + , + warning: selectedCompareChapter ? "" : "当前还没有稳定的 compare 目标章节。", + active: Boolean(selectedCompareChapter?.chapter_index && Number(authorState.selectedAuthorContinuityChapterIndex || 0) === Number(selectedCompareChapter.chapter_index)), + actions: [ + ...(selectedCompareChapter?.chapter_index ? [{ + label: "打开 Compare", + primary: true, + onClick: () => { + authorState.selectedAuthorContinuityChapterIndex = selectedCompareChapter.chapter_index; + renderAuthorReports(); + jumpToAuthorChapter(selectedCompareChapter.chapter_index, "compare"); + }, + }] : []), + { + label: "去 Review 提交证据", + onClick: () => focusAuthorPanel("diff"), + }, + ] + }); + workGrid.appendChild(compareCard); + + dom.authorGuidedFocus.appendChild(workGrid); + dom.authorGuidedFocus.appendChild(actions); + return; + } + if (!workflow) { + const hasActiveDraft = Boolean(authorState.activeDraftVersionId); + if (titleNode) { + titleNode.textContent = hasActiveDraft ? "先围绕当前 Draft 进入工作状态" : "先定题材,再生成今天要打磨的 Draft"; + } + if (copyNode) { + copyNode.textContent = hasActiveDraft + ? "当前已经有可工作的 Draft。先看摘要,再决定这轮是继续打磨素材,还是直接进入 Simulate。" + : "如果你还没有工作中的 Draft,就先写 Brief;如果已经有世界设定,可以直接复制当前世界进入作者工作流。"; + } + const card = document.createElement("article"); + card.className = "list-card author-guided-focus-card"; + card.innerHTML = hasActiveDraft ? ` +
推荐下一步
+

先确认当前 Draft 的健康状态,再决定往哪一段继续推进

+

你已经有一个可工作的 Draft。现在最值当的两件事,是先看当前 Draft 摘要,或者直接进入 Simulate 看 weakest chapters。

+
+ 当前 Draft ${authorState.activeDraftVersionId} + 适合回流作者重新进入工作状态 +
+ ` : ` +
推荐下一步
+

先建立一个可工作的 Draft

+

作者首页只做两件事:确认今天要处理哪一个 Draft,以及决定下一步是写 Brief 还是继续打磨。

+
+ 如果没有 Draft:先去 Brief + 如果已有世界:直接复制当前世界 +
+ `; + const actions = document.createElement("div"); + actions.className = "composer-actions author-guided-actions"; + if (hasActiveDraft) { + const detailButton = document.createElement("button"); + detailButton.className = "primary-action"; + detailButton.textContent = "查看 Draft 摘要"; + detailButton.addEventListener("click", () => focusAuthorPanel("draft_detail")); + const simulateButton = document.createElement("button"); + simulateButton.className = "ghost-action"; + simulateButton.textContent = "打开 Simulate"; + simulateButton.addEventListener("click", () => focusAuthorPanel("simulation")); + actions.appendChild(detailButton); + actions.appendChild(simulateButton); + } else { + const briefButton = document.createElement("button"); + briefButton.className = "primary-action"; + briefButton.textContent = "打开 Brief"; + briefButton.addEventListener("click", () => focusAuthorPanel("brief")); + const copyButton = document.createElement("button"); + copyButton.className = "ghost-action"; + copyButton.textContent = "复制当前世界"; + copyButton.addEventListener("click", async () => { + await createDraftFromCurrentWorld(); + }); + actions.appendChild(briefButton); + actions.appendChild(copyButton); + } + dom.authorGuidedFocus.appendChild(card); + dom.authorGuidedFocus.appendChild(actions); + return; + } + if (titleNode) { + titleNode.textContent = workflow.draft_title || "围绕当前 Draft 推进下一阶段"; + } + if (copyNode) { + copyNode.textContent = `${authorStageLabel(workflow.stage)} · 推荐动作 ${primaryAction?.label || workflow.recommended_action || "-"}。先处理这一件事,再进入其他深层编辑器。`; + } + const blockers = workflow.blockers || []; + const readiness = workflow.longform_readiness || {}; + const card = document.createElement("article"); + card.className = "list-card author-guided-focus-card"; + card.innerHTML = ` +
推荐下一步
+

${authorRecommendedActionLabel(primaryAction?.action_id || workflow.recommended_action)}

+

${workflow.draft_title || "当前 Draft"} · 当前阶段 ${authorStageLabel(workflow.stage)}。${blockers.length ? `当前 blocker:${blockers[0].message}` : "当前没有 blocker,可以直接推进。"}

+
+ 推荐入口 ${authorRecommendedWorkspaceLabel(primaryAction?.action_id || workflow.recommended_action)} + 长线 ${workflow.entry_mode === "structured_longform" ? "结构化" : "Quick Brief"} / ${readiness.status || "-"} + Validation ${workflow.validation_summary?.status || "-"} + Simulation ${workflow.simulation_summary?.latest_decision || "-"} +
+ `; + dom.authorGuidedFocus.appendChild(card); + if (primaryAction) { + const actions = document.createElement("div"); + actions.className = "composer-actions author-guided-actions"; + const button = document.createElement("button"); + button.className = primaryAction.primary === false ? "ghost-action" : "primary-action"; + button.textContent = primaryAction.label || primaryAction.action_id; + button.disabled = primaryAction.enabled === false; + button.title = primaryAction.reason || ""; + button.addEventListener("click", async () => { + await runAuthorWorkflowAction(primaryAction.action_id); + }); + actions.appendChild(button); + if (authorState.activeDraftVersionId) { + const secondary = document.createElement("button"); + secondary.className = "ghost-action"; + secondary.textContent = "查看当前 Draft 摘要"; + secondary.addEventListener("click", () => focusAuthorPanel("draft_detail")); + actions.appendChild(secondary); + } + dom.authorGuidedFocus.appendChild(actions); + } +} + +function renderAuthorReports() { + renderAuthorAuthStatus(); + renderAuthorSettingsSummary(); + const currentSimulationReport = currentAuthorSimulationReport(); + dom.authorActiveDraft.textContent = authorState.activeDraftVersionId || "-"; + dom.authorValidationStatus.textContent = authorState.authorValidationReport?.status || (authorState.authorValidationReport?.ok ? "ok" : "未运行"); + dom.authorSimulationChapters.textContent = String(currentSimulationReport?.completed_chapters || 0); + const saveDraftAccess = authorState.authorAccessSnapshot?.actions?.save_draft || null; + const briefAccess = authorState.authorAccessSnapshot?.actions?.draft_from_brief || null; + const simulateAccess = authorState.authorAccessSnapshot?.actions?.simulate || null; + if (dom.authorBriefAccess) { + dom.authorBriefAccess.textContent = gatingStatusLabel(briefAccess); + dom.authorBriefAccess.title = gatingHint(briefAccess); + } + if (dom.authorSimulateAccess) { + dom.authorSimulateAccess.textContent = gatingStatusLabel(simulateAccess); + dom.authorSimulateAccess.title = gatingHint(simulateAccess); + } + if (dom.authorCreateDraftFromBrief) { + dom.authorCreateDraftFromBrief.disabled = Boolean(briefAccess && !briefAccess.allowed); + dom.authorCreateDraftFromBrief.title = gatingHint(briefAccess); + } + if (dom.authorCreateDraft) { + dom.authorCreateDraft.disabled = Boolean(saveDraftAccess && !saveDraftAccess.allowed); + dom.authorCreateDraft.title = gatingHint(saveDraftAccess); + } + renderAuthorDraftSectionNav(); + renderAuthorDraftSectionSummary(); + syncAuthorDraftSectionPanels(); + renderAuthorSteeringComposer(); + renderAuthorGuidedFocus(); + renderAuthorWorkflow(); + renderAuthorDraftDetail(); + renderAuthorWorkStudio(); + renderAuthorSimulateSummary(); + renderAuthorReviewSummary(); + renderAuthorRevisionPanels(); + renderAuthorCompare(); + renderAuthorCollaboration(); + clearNode(dom.authorValidationReport); + const validationPayload = authorState.authorValidationReport || authorState.activeDraftDetail?.validation_report || null; + const validationDrilldown = authorState.authorValidationReport?.validation_drilldown || authorState.activeDraftDetail?.validation_drilldown || {}; + if (validationPayload) { + const node = document.createElement("article"); + node.className = "list-card"; + const validation = validationPayload; + node.innerHTML = ` +
+

Validation / Submit 结果

+ ${validation.status || (validation.ok ? "ok" : "pending")} +
+

ok: ${validation.ok ? "true" : "false"}\nerrors: ${(validation.errors || []).length || 0}\nwarnings: ${(validation.warnings || []).length || 0}\n\nblockers:\n${(validationDrilldown.blockers || []).map((item) => `${item.category} · ${item.severity}\n${item.message}\n建议:${item.recommended_action}`).join("\n\n") || (validation.errors || []).join("\n") || "-"}\n\nwarnings:\n${(validationDrilldown.warning_groups || []).map((item) => `${item.category} · ${item.message}\n建议:${item.recommended_action}`).join("\n\n") || (validation.warnings || []).join("\n") || "-"}\n\nnext actions:\n${(validationDrilldown.next_actions || []).join("\n") || "-"}

+ `; + dom.authorValidationReport.appendChild(node); + } else { + clearNode(dom.authorValidationReport, "选择一个 draft 后,这里会显示 validation report。"); + } + clearNode(dom.authorSimulationReport); + const simulationReport = currentSimulationReport; + const simulationDrilldown = getSimulationDrilldown(); + if (simulationReport) { + const topIssues = simulationReport.evaluation_summary?.top_issue_categories || []; + const failingPacks = simulationReport.top_failing_packs || opsState.opsCrossPackQuality?.top_failing_packs || []; + const metricDeltas = simulationReport.metric_deltas || {}; + const deltaSummary = simulationReport.cross_pack_summary?.delta_summary || opsState.opsCrossPackQuality?.delta_summary || {}; + const currentDiagnosis = simulationReport.cross_pack_summary?.worlds?.find( + (item) => item.world_id === authorState.activeDraftDetail?.world_id + ); + const diffSummary = buildSimulationDiffSummary(authorState.authorPreviousSimulationReport, simulationReport); + + dom.authorSimulationReport.appendChild( + createListCard({ + title: "Simulation 概览", + score: simulationReport.ok ? "ok" : "warn", + body: + `完成章节 ${simulationReport.completed_chapters || 0} / ${simulationDrilldown.chapter_budget || simulationReport.chapter_budget || "-"} · latest ${simulationReport.latest_decision || "-"}\n` + + `completion ${simulationDrilldown.completion_ratio !== undefined ? Number(simulationDrilldown.completion_ratio).toFixed(3) : "-"} · stop ${simulationDrilldown.stop_reason || simulationReport.stop_reason || "-"}\n` + + `pass ${formatPercent(simulationReport.evaluation_summary?.pass_rate)} · rewrite ${formatPercent(simulationReport.evaluation_summary?.rewrite_rate)} · block ${formatPercent(simulationReport.evaluation_summary?.block_rate)}\n` + + `${currentDiagnosis ? `当前 Draft 诊断:${currentDiagnosis.issue_summary?.dominant_issue || "-"} · ${(currentDiagnosis.issue_summary?.weakest_dimensions || []).map((item) => `${item.name}=${Number(item.value || 0).toFixed(3)}`).join(" / ") || "-"}` : "当前 Draft 诊断:-"}\n` + + `${Object.keys(metricDeltas).length ? `指标 delta:${Object.entries(metricDeltas).map(([key, value]) => `${key}=${Number(value).toFixed(3)}`).join(" / ")}` : "指标 delta:-"}\n` + + `${diffSummary ? `与上次 simulation 对比:\n${diffSummary}` : "与上次 simulation 对比:-"}\n` + + `${typeof deltaSummary.cross_pack_pass_rate_delta === "number" ? `cross-pack pass rate delta: ${deltaSummary.cross_pack_pass_rate_delta >= 0 ? "+" : ""}${deltaSummary.cross_pack_pass_rate_delta.toFixed(3)}` : "cross-pack pass rate delta: -"}` + }) + ); + + dom.authorSimulationReport.appendChild( + createListCard({ + title: "Issue / Module Drill-down", + score: `${(simulationDrilldown.issue_histogram || []).length} 类`, + body: + `${(simulationDrilldown.issue_histogram || []).length ? `issue histogram:\n${simulationDrilldown.issue_histogram.map((item) => `${item.issue_code} · ${item.count} · ${item.owning_module || "-"}`).join("\n")}` : "issue histogram: -"}\n\n` + + `${(simulationDrilldown.module_histogram || []).length ? `module histogram:\n${simulationDrilldown.module_histogram.map((item) => `${item.owning_module} · ${item.count} · ${(item.issue_codes || []).join("/") || "-"}`).join("\n")}` : "module histogram: -"}\n\n` + + `${Object.keys(simulationDrilldown.decision_histogram || {}).length ? `decision histogram:\n${Object.entries(simulationDrilldown.decision_histogram || {}).map(([key, value]) => `${key}: ${value}`).join("\n")}` : "decision histogram: -"}\n\n` + + `${Object.keys(simulationDrilldown.story_phase_histogram || {}).length ? `story phases:\n${Object.entries(simulationDrilldown.story_phase_histogram || {}).map(([key, value]) => `${key}: ${value}`).join("\n")}` : "story phases: -"}\n\n` + + `${Object.keys(simulationDrilldown.scene_function_histogram || {}).length ? `scene functions:\n${Object.entries(simulationDrilldown.scene_function_histogram || {}).map(([key, value]) => `${key}: ${value}`).join("\n")}` : "scene functions: -"}\n\n` + + `${(simulationDrilldown.next_actions || topIssues || []).length ? `next actions:\n${(simulationDrilldown.next_actions || topIssues || []).map((item, index) => `${index + 1}. ${item.issue_code} -> ${item.owning_module}\n建议:${item.fix_hint}`).join("\n\n")}` : "next actions: -"}\n\n` + + `${simulationDrilldown.quality_pass_summary?.action_histogram?.length ? `quality pass:\nchapters touched ${simulationDrilldown.quality_pass_summary.chapters_touched}\n${simulationDrilldown.quality_pass_summary.action_histogram.map((item) => `${item.action}: ${item.count}`).join("\n")}` : "quality pass: -"}` + }) + ); + + dom.authorSimulationReport.appendChild( + createListCard({ + title: "Issue Focus Queue", + score: `${(simulationDrilldown.issue_focus_queue || []).length} 项`, + body: + `${(simulationDrilldown.issue_focus_queue || []).map((item) => `${item.issue_code} · ${item.count} · ${item.owning_module || "-"}\n建议:${item.fix_hint || "-"}\n章节:${(item.chapter_targets || []).map((chapter) => `${chapter.chapter_index}.${chapter.chapter_title}(${chapter.scene_function || "-"}/${chapter.decision || "-"})`).join(" / ") || "-"}`).join("\n\n") || "暂无 issue focus queue。"}` + }) + ); + if ((simulationDrilldown.issue_focus_queue || [])[0]?.chapter_targets?.[0]) { + const queueActions = document.createElement("div"); + queueActions.className = "composer-actions author-simulate-actions"; + const firstTarget = simulationDrilldown.issue_focus_queue[0].chapter_targets[0]; + const openButton = document.createElement("button"); + openButton.className = "primary-action"; + openButton.textContent = "跳到问题章节"; + openButton.addEventListener("click", () => { + jumpToAuthorChapter(firstTarget.chapter_index, "simulation"); + }); + const commentButton = document.createElement("button"); + commentButton.className = "ghost-action"; + commentButton.textContent = "评论首个问题章节"; + commentButton.addEventListener("click", () => { + prefillAuthorCommentAnchor("simulation", String(firstTarget.chapter_index)); + }); + const draftButton = document.createElement("button"); + draftButton.className = "ghost-action"; + draftButton.textContent = "回 Draft Workbench"; + draftButton.addEventListener("click", () => { + focusAuthorPanel("longform"); + }); + queueActions.appendChild(openButton); + queueActions.appendChild(commentButton); + queueActions.appendChild(draftButton); + dom.authorSimulationReport.appendChild(queueActions); + } + + dom.authorSimulationReport.appendChild( + createListCard({ + title: "Weakest Chapters", + score: `${(simulationDrilldown.weakest_chapters || []).length} 章`, + body: + `${(simulationDrilldown.weakest_chapters || []).map((item) => `${item.chapter_index}. ${item.chapter_title || item.chapter_id}\n${item.decision} · score ${Number(item.overall_score || 0).toFixed(3)} · scene ${item.scene_function || "-"}\nissues ${(item.issue_codes || []).join(" / ") || "-"}\nsignals rep ${Number(item.signal_snapshot?.repetition_score || 0).toFixed(3)} · expo ${Number(item.signal_snapshot?.exposition_ratio || 0).toFixed(3)} · hook ${Number(item.signal_snapshot?.hook_quality || 0).toFixed(3)} · detail ${Number(item.signal_snapshot?.concrete_detail_density || 0).toFixed(3)}\nquality pass ${(item.quality_pass_actions || []).join(" / ") || "-"}`).join("\n\n") || "暂无章节级弱项。"}` + }) + ); + if ((simulationDrilldown.weakest_chapters || [])[0]) { + const weakestActions = document.createElement("div"); + weakestActions.className = "composer-actions author-simulate-actions"; + const firstWeak = simulationDrilldown.weakest_chapters[0]; + const reviewButton = document.createElement("button"); + reviewButton.className = "primary-action"; + reviewButton.textContent = `查看第 ${firstWeak.chapter_index} 章`; + reviewButton.addEventListener("click", () => { + jumpToAuthorChapter(firstWeak.chapter_index, "simulation"); + }); + const compareButton = document.createElement("button"); + compareButton.className = "ghost-action"; + compareButton.textContent = "去 Review 对照"; + compareButton.addEventListener("click", () => { + jumpToAuthorChapter(firstWeak.chapter_index, "compare"); + }); + const draftButton = document.createElement("button"); + draftButton.className = "ghost-action"; + draftButton.textContent = "回 Draft 调整素材"; + draftButton.addEventListener("click", () => { + focusAuthorPanel("longform"); + }); + weakestActions.appendChild(reviewButton); + weakestActions.appendChild(compareButton); + weakestActions.appendChild(draftButton); + dom.authorSimulationReport.appendChild(weakestActions); + } + + dom.authorSimulationReport.appendChild( + createListCard({ + title: "Chapter Drill-down", + score: `${(simulationDrilldown.chapter_breakdown || []).length} 章`, + body: + `${(simulationDrilldown.chapter_breakdown || []).map((item) => `${item.chapter_index}. ${item.chapter_title || item.chapter_id}\n${item.decision} · score ${Number(item.overall_score || 0).toFixed(3)} · scene ${item.scene_function || "-"}\nissues ${(item.issue_codes || []).join(" / ") || "-"}\nchoices ${(item.choices_preview || []).join(" / ") || "-"}\nquality pass ${(item.quality_pass_actions || []).join(" / ") || "-"}\ncritic signals ${item.critic_signal_count ?? 0}`).join("\n\n") || "暂无 chapter breakdown。"}` + }) + ); + } else { + clearNode(dom.authorSimulationReport, "运行 simulation 后,这里会显示 route length、reader leak 与 cost estimate。"); + } + renderAuthorCreativeCockpit(); + const worldpack = getActiveDraftWorldpack() || {}; + const stylePack = worldpack.narrative_style_pack || {}; + const dialogueBundle = { + dialogue_realism_policy: worldpack.dialogue_realism_policy || {}, + voice_profiles: worldpack.voice_profiles || stylePack.dialogue?.voice_profiles || {}, + response_cadence_profiles: worldpack.response_cadence_profiles || stylePack.dialogue?.response_profiles || {}, + pressure_response_styles: worldpack.pressure_response_styles || stylePack.dialogue?.pressure_styles || {}, + }; + dom.authorVoiceEditor.value = JSON.stringify(dialogueBundle, null, 2); + dom.authorActionEditor.value = JSON.stringify( + worldpack.emotion_action_policies || { default: stylePack.emotion_actions || {} }, + null, + 2 + ); + dom.authorSensoryEditor.value = JSON.stringify( + worldpack.sensory_grounding_policies || { default: stylePack.sensory_grounding || {} }, + null, + 2 + ); + dom.authorSceneEditor.value = JSON.stringify( + worldpack.scene_realization_contracts || { default: stylePack.scene_realization || {} }, + null, + 2 + ); + renderStylePacingHookControls(); + renderCharacterEditor(); + renderSceneEditor(); +} + +async function refreshAuthorSurface() { + await hydrateAuthorAuthSession(); + const authenticatedAuthorAccountIdValue = String(authorState.authorAuthSession?.identity?.account_id || "").trim(); + const allowAuthorDraftRequests = hasAuthorAuthenticatedSession(); + let activeAuthorAccountIdValue = + dom.authorAccountId?.value.trim() || + authenticatedAuthorAccountIdValue || + ""; + if (dom.authorAccountId && !dom.authorAccountId.value.trim() && activeAuthorAccountIdValue) { + dom.authorAccountId.value = activeAuthorAccountIdValue; + } + if (resumeAuthorDeepLinkIfPossible(authenticatedAuthorAccountIdValue)) { + return; + } + ensureAuthorDeepLinkLoginPrompt(); + ensureAuthorDeepLinkAccountSwitchPrompt(authenticatedAuthorAccountIdValue); + const allowAuthorAccessRequests = allowAuthorDraftRequests; + const allowReviewerInboxRequests = authorSessionCanReview(); + const showAuthorSettings = shellState.authorWorkspace === "settings"; + const showAuthorReview = shellState.authorWorkspace === "review"; + if (!authorState.authorBriefTemplate) { + try { + authorState.authorBriefTemplate = await api("/v1/author/brief-template"); + populateAuthorBriefForm(); + } catch (error) { + console.warn("brief template unavailable", error); + } + } + if (allowAuthorDraftRequests) { + const payload = await api("/v1/author/drafts"); + authorState.authorDrafts = payload.drafts; + if (!authorState.activeDraftVersionId && authorState.authorDrafts.length) { + authorState.activeDraftVersionId = preferredAuthorDraftVersionId(authorState.authorDrafts); + } + if (authorState.activeDraftVersionId) { + try { + authorState.activeDraftDetail = await api(`/v1/author/drafts/${authorState.activeDraftVersionId}`); + } catch (_error) { + authorState.activeDraftDetail = null; + authorState.activeDraftVersionId = null; + } + } else { + authorState.activeDraftDetail = null; + } + } else { + authorState.authorDrafts = []; + authorState.activeDraftDetail = null; + } + normalizeAuthorDraftRouteAccount(); + activeAuthorAccountIdValue = + dom.authorAccountId?.value.trim() || + String(authorState.authorAuthSession?.identity?.account_id || "").trim() || + ""; + if (resumeAuthorDeepLinkIfPossible(String(authorState.authorAuthSession?.identity?.account_id || "").trim())) { + return; + } + ensureAuthorDeepLinkLoginPrompt(); + ensureAuthorDeepLinkAccountSwitchPrompt(String(authorState.authorAuthSession?.identity?.account_id || "").trim()); + if (allowAuthorDraftRequests) { + try { + await refreshAuthorWorks(activeAuthorAccountIdValue); + } catch (_error) { + authorState.authorWorks = []; + authorState.activeWorkId = null; + authorState.activeWorkDetail = null; + authorState.activeWorkChapterIndex = null; + authorState.activeWorkChapterDetail = null; + authorState.authorWorkDiagnostics = null; + } + } else { + authorState.authorWorks = []; + authorState.activeWorkId = null; + authorState.activeWorkDetail = null; + authorState.activeWorkChapterIndex = null; + authorState.activeWorkChapterDetail = null; + authorState.activeWorkChapterDraft = null; + authorState.activeWorkChapterDirty = false; + authorState.authorWorkDiagnostics = null; + authorState.authorWorkQualityGateFailure = null; + } + if (authorState.activeDraftVersionId && allowAuthorDraftRequests) { + try { + authorState.authorCollaborationSummary = await api(`/v1/author/drafts/${authorState.activeDraftVersionId}/collaboration`); + const availableThreadIds = new Set((authorState.authorCollaborationSummary?.threads || []).map((item) => item.thread_id)); + if (authorState.selectedAuthorThreadId && !availableThreadIds.has(authorState.selectedAuthorThreadId)) { + authorState.selectedAuthorThreadId = null; + } + if (!authorState.selectedAuthorThreadId && availableThreadIds.size) { + authorState.selectedAuthorThreadId = Array.from(availableThreadIds)[0]; + } + } catch (error) { + authorState.authorCollaborationSummary = null; + authorState.selectedAuthorThreadId = null; + } + } else { + authorState.authorCollaborationSummary = null; + authorState.selectedAuthorThreadId = null; + } + if (dom.authorApprovalReviewer?.value.trim() && !dom.authorInboxReviewerId?.value.trim()) { + dom.authorInboxReviewerId.value = dom.authorApprovalReviewer.value.trim(); + } + if (showAuthorSettings && allowReviewerInboxRequests) { + try { + await refreshAuthorReviewerInbox(); + } catch (error) { + authorState.authorReviewerInbox = null; + authorState.authorReviewerInboxNextCursor = null; + authorState.authorReviewerInboxHasMore = false; + } + } else { + authorState.authorReviewerInbox = null; + authorState.authorReviewerInboxNextCursor = null; + authorState.authorReviewerInboxHasMore = false; + } + if (showAuthorSettings) { + try { + await refreshAuthorNotificationPreferences(); + } catch (error) { + authorState.authorNotificationPreferences = null; + } + } else { + authorState.authorNotificationPreferences = null; + } + if (activeAuthorAccountIdValue && allowAuthorAccessRequests) { + try { + authorState.authorAccessSnapshot = await api( + `/v1/author/access?account_id=${encodeURIComponent(activeAuthorAccountIdValue)}${ + authorState.activeDraftVersionId ? `&world_version_id=${encodeURIComponent(authorState.activeDraftVersionId)}` : "" + }` + ); + } catch (error) { + authorState.authorAccessSnapshot = null; + } + } else { + authorState.authorAccessSnapshot = null; + } + if (activeAuthorAccountIdValue && allowAuthorAccessRequests) { + try { + const query = new URLSearchParams(); + query.set("account_id", activeAuthorAccountIdValue); + if (authorState.activeDraftVersionId) { + query.set("world_version_id", authorState.activeDraftVersionId); + } + authorState.authorWorkflowSummary = await api(`/v1/author/workflow?${query.toString()}`); + if (!authorState.activeDraftVersionId && authorState.authorWorkflowSummary?.world_version_id) { + authorState.activeDraftVersionId = authorState.authorWorkflowSummary.world_version_id; + authorState.activeDraftDetail = await api(`/v1/author/drafts/${authorState.activeDraftVersionId}`); + } + } catch (error) { + authorState.authorWorkflowSummary = null; + } + } else { + authorState.authorWorkflowSummary = null; + } + if (activeAuthorAccountIdValue && allowAuthorAccessRequests && !showAuthorReview) { + try { + const entitlements = await api(`/v1/reader/entitlements?account_id=${encodeURIComponent(activeAuthorAccountIdValue)}`); + dom.authorStudioCredits.textContent = String(Number(entitlements.wallets?.studio_credits?.balance || 0).toFixed(0)); + if (dom.authorTier) { + dom.authorTier.textContent = tierLabel(entitlements.subscription?.tier_id) || entitlements.subscription?.tier_id || "-"; + } + } catch (error) { + dom.authorStudioCredits.textContent = "-"; + if (dom.authorTier) { + dom.authorTier.textContent = "-"; + } + } + } else { + dom.authorStudioCredits.textContent = "-"; + if (dom.authorTier) { + dom.authorTier.textContent = "-"; + } + } + renderAuthorDrafts(); + renderAuthorReports(); +} + +async function createDraftFromCurrentWorld() { + if (!readerState.worldId) { + authorNotice("先选择一个世界。"); + return; + } + try { + const detail = await api(`/v1/library/worlds/${readerState.worldId}`); + const pack = detail.worldpack; + pack.version = `${pack.version}-draft-${Date.now()}`; + pack.manifest.author_id = dom.authorAccountId?.value.trim() || "web_author"; + const draft = await api("/v1/author/drafts", { + method: "POST", + body: JSON.stringify({ + worldpack: pack, + account_id: dom.authorAccountId?.value.trim() || "web_author", + change_context: { source: "manual_update", label: "从当前世界复制" }, + }), + }); + authorState.activeDraftVersionId = draft.world_version_id; + authorState.activeDraftDetail = await api(`/v1/author/drafts/${draft.world_version_id}`); + authorState.selectedAuthorRevisionIndex = null; + authorState.authorValidationReport = draft.validation_report; + authorState.authorSimulationReport = null; + await refreshAuthorSurface(); + await refreshOpsSurfaceIfVisible(); + if (draft.requires_structured_longform) { + focusAuthorPanel("longform"); + authorNotice( + `当前 quick brief 只会直接承诺到 100 章;这份 Draft 目标是 ${draft.requested_target_band || draft.requested_target_chapters || ">100"},需要先进入结构化长篇蓝图补齐 readiness。`, + "warning" + ); + return; + } + focusAuthorPanel("draft_detail"); + } catch (error) { + const detail = parseErrorDetail(error); + await refreshAuthorSurface(); + if (detail?.code === "author_entitlement_required") { + alertAuthorGating(detail, "创建 Draft"); + return; + } + authorNotice(formatAuthorApiErrorMessage(error, "创建 Draft 失败,请稍后再试。"), "error"); + } +} + +async function createDraftFromBrief() { + const brief = buildAuthorBriefPayload(); + if (!brief.world_title || !brief.core_premise) { + authorNotice("请至少填写世界标题和故事 brief。"); + return; + } + try { + const draft = await api("/v1/author/drafts/from-brief", { + method: "POST", + body: JSON.stringify({ brief }), + }); + authorState.activeDraftVersionId = draft.world_version_id; + authorState.activeDraftDetail = await api(`/v1/author/drafts/${draft.world_version_id}`); + authorState.selectedAuthorRevisionIndex = null; + authorState.authorValidationReport = draft.validation_report; + authorState.authorSimulationReport = null; + await refreshAuthorSurface(); + await refreshOpsSurfaceIfVisible(); + if (draft.requires_structured_longform) { + focusAuthorPanel("longform"); + authorNotice( + `当前 quick brief 只会直接承诺到 100 章;这份 Draft 目标是 ${draft.requested_target_band || draft.requested_target_chapters || ">100"},需要先进入结构化长篇蓝图补齐 readiness。`, + "warning" + ); + return; + } + focusAuthorPanel("draft_detail"); + } catch (error) { + const detail = parseErrorDetail(error); + await refreshAuthorSurface(); + if (detail?.code === "author_entitlement_required") { + authorNotice(`当前不能创建 Draft:${accessReasonLabel(detail.reason)}。需要 ${detail.required_display_name || tierLabel(detail.required_tier)},当前 ${detail.wallet_type || "-"} 余额 ${Number(detail.balance || 0).toFixed(0)}。`); + return; + } + authorNotice(formatAuthorApiErrorMessage(error, "生成 Draft 失败,请稍后再试。"), "error"); + } +} + +async function saveCapabilityAssets() { + const activeWorldpack = getActiveDraftWorldpack(); + if (!activeWorldpack || !authorState.activeDraftVersionId) { + authorNotice("先选择一个 draft。"); + return; + } + const worldpack = structuredClone(activeWorldpack); + worldpack.narrative_style_pack = worldpack.narrative_style_pack || {}; + try { + const dialogueBundle = JSON.parse(dom.authorVoiceEditor.value || "{}"); + worldpack.dialogue_realism_policy = dialogueBundle.dialogue_realism_policy || {}; + worldpack.voice_profiles = dialogueBundle.voice_profiles || {}; + worldpack.response_cadence_profiles = dialogueBundle.response_cadence_profiles || {}; + worldpack.pressure_response_styles = dialogueBundle.pressure_response_styles || {}; + worldpack.emotion_action_policies = JSON.parse(dom.authorActionEditor.value || "{}"); + worldpack.sensory_grounding_policies = JSON.parse(dom.authorSensoryEditor.value || "{}"); + worldpack.scene_realization_contracts = JSON.parse(dom.authorSceneEditor.value || "{}"); + worldpack.narrative_style_pack.dialogue = { + ...worldpack.dialogue_realism_policy, + voice_profiles: worldpack.voice_profiles, + response_profiles: worldpack.response_cadence_profiles, + pressure_styles: worldpack.pressure_response_styles, + }; + applyStylePacingHookControls(worldpack); + worldpack.narrative_style_pack.emotion_actions = Object.values(worldpack.emotion_action_policies)[0] || {}; + worldpack.narrative_style_pack.sensory_grounding = Object.values(worldpack.sensory_grounding_policies)[0] || {}; + worldpack.narrative_style_pack.scene_realization = Object.values(worldpack.scene_realization_contracts)[0] || {}; + } catch (error) { + authorNotice("能力配置 JSON 解析失败,请检查格式。", "error"); + return; + } + try { + const draft = await api(`/v1/author/drafts/${authorState.activeDraftVersionId}`, { + method: "PUT", + body: JSON.stringify({ + worldpack, + account_id: dom.authorAccountId?.value.trim() || "web_author", + change_context: { source: "capability_editor", label: "保存能力配置" }, + }), + }); + authorState.activeDraftVersionId = draft.world_version_id || authorState.activeDraftVersionId; + authorState.activeDraftDetail = await api(`/v1/author/drafts/${authorState.activeDraftVersionId}`); + authorState.selectedAuthorRevisionIndex = null; + authorState.authorValidationReport = draft.validation_report || authorState.activeDraftDetail.validation_report; + await refreshAuthorSurface(); + await refreshOpsSurfaceIfVisible(); + focusAuthorPanel("diff"); + } catch (error) { + const detail = parseErrorDetail(error); + await refreshAuthorSurface(); + if (detail?.code === "author_entitlement_required") { + alertAuthorGating(detail, "保存能力配置"); + return; + } + authorNotice(formatAuthorApiErrorMessage(error, "保存能力配置失败,请稍后再试。"), "error"); + } +} + +async function saveCharacterCard() { + const activeWorldpack = getActiveDraftWorldpack(); + if (!activeWorldpack || !authorState.activeDraftVersionId) { + authorNotice("先选择一个 draft。"); + return; + } + const worldpack = structuredClone(activeWorldpack); + const characters = worldpack.characters || []; + if (!characters.length) { + authorNotice("当前 draft 没有可编辑角色。"); + return; + } + const index = Math.min(selectedCharacterIndex(), characters.length - 1); + const character = characters[index]; + character.display_name = dom.authorCharacterName.value.trim(); + character.role = dom.authorCharacterRole.value.trim() || character.role; + character.destiny_contract = character.destiny_contract || {}; + character.destiny_contract.life_theme = dom.authorCharacterLifeTheme.value.trim(); + character.wound_profile = character.wound_profile || {}; + character.wound_profile.core_wound = dom.authorCharacterCoreWound.value.trim(); + character.wound_profile.public_self = dom.authorCharacterPublicSelf.value.trim(); + character.wound_profile.shadow_desire = dom.authorCharacterShadowDesire.value.trim(); + character.vow_profile = character.vow_profile || {}; + character.vow_profile.vows = dom.authorCharacterVows.value + .split("\n") + .map((item) => item.trim()) + .filter(Boolean); + + try { + const draft = await api(`/v1/author/drafts/${authorState.activeDraftVersionId}`, { + method: "PUT", + body: JSON.stringify({ + worldpack, + account_id: dom.authorAccountId?.value.trim() || "web_author", + change_context: buildDraftChangeContext("character_editor", "保存角色卡", "character_card"), + }), + }); + syncAuthorRepairLoopHintFromDraft(draft, "character_card"); + authorState.activeDraftDetail = await api(`/v1/author/drafts/${authorState.activeDraftVersionId}`); + authorState.selectedAuthorRevisionIndex = null; + authorState.authorValidationReport = draft.validation_report || authorState.activeDraftDetail.validation_report; + await refreshAuthorSurface(); + focusAuthorPanel("diff"); + } catch (error) { + const detail = parseErrorDetail(error); + await refreshAuthorSurface(); + if (detail?.code === "author_entitlement_required") { + alertAuthorGating(detail, "保存角色卡"); + return; + } + authorNotice(formatAuthorApiErrorMessage(error, "保存角色卡失败,请稍后再试。"), "error"); + } +} + +async function saveSceneBlueprint() { + const activeWorldpack = getActiveDraftWorldpack(); + if (!activeWorldpack || !authorState.activeDraftVersionId) { + authorNotice("先选择一个 draft。"); + return; + } + const worldpack = structuredClone(activeWorldpack); + const scenes = worldpack.scene_blueprints || []; + if (!scenes.length) { + authorNotice("当前 draft 没有可编辑场景。"); + return; + } + const index = Math.min(selectedSceneIndex(), scenes.length - 1); + const scene = scenes[index]; + scene.scene_id = dom.authorSceneId.value.trim() || scene.scene_id; + scene.scene_function = dom.authorSceneFunction.value.trim() || scene.scene_function; + scene.required_roles = dom.authorSceneRequiredRoles.value + .split("\n") + .map((item) => item.trim()) + .filter(Boolean); + scene.beats_template = dom.authorSceneBeats.value + .split("\n") + .map((item) => item.trim()) + .filter(Boolean); + + try { + const draft = await api(`/v1/author/drafts/${authorState.activeDraftVersionId}`, { + method: "PUT", + body: JSON.stringify({ + worldpack, + account_id: dom.authorAccountId?.value.trim() || "web_author", + change_context: buildDraftChangeContext("scene_editor", "保存场景蓝图", "scene_blueprint"), + }), + }); + syncAuthorRepairLoopHintFromDraft(draft, "scene_blueprint"); + authorState.activeDraftDetail = await api(`/v1/author/drafts/${authorState.activeDraftVersionId}`); + authorState.selectedAuthorRevisionIndex = null; + authorState.authorValidationReport = draft.validation_report || authorState.activeDraftDetail.validation_report; + await refreshAuthorSurface(); + focusAuthorPanel("diff"); + } catch (error) { + const detail = parseErrorDetail(error); + await refreshAuthorSurface(); + if (detail?.code === "author_entitlement_required") { + alertAuthorGating(detail, "保存场景蓝图"); + return; + } + authorNotice(formatAuthorApiErrorMessage(error, "保存场景蓝图失败,请稍后再试。"), "error"); + } +} + +async function bootstrapLongformWorkbench() { + if (!authorState.activeDraftVersionId) { + authorNotice("先选择一个 draft。"); + return; + } + try { + const draft = await api(`/v1/author/drafts/${authorState.activeDraftVersionId}/longform-bootstrap`, { + method: "POST", + body: JSON.stringify({ + account_id: dom.authorAccountId?.value.trim() || "web_author", + }), + }); + authorState.activeDraftDetail = await api(`/v1/author/drafts/${authorState.activeDraftVersionId}`); + authorState.selectedAuthorRevisionIndex = null; + authorState.authorValidationReport = draft.validation_report || authorState.activeDraftDetail.validation_report; + await refreshAuthorSurface(); + focusAuthorPanel("draft_detail"); + } catch (error) { + const detail = parseErrorDetail(error); + await refreshAuthorSurface(); + if (detail?.code === "author_entitlement_required") { + alertAuthorGating(detail, "生成 Longform Plan"); + return; + } + authorNotice(formatAuthorApiErrorMessage(error, "生成 Longform Plan 失败,请稍后再试。"), "error"); + } +} + +async function saveLongformWorkbench() { + const activeWorldpack = getActiveDraftWorldpack(); + if (!activeWorldpack || !authorState.activeDraftVersionId) { + authorNotice("先选择一个 draft。"); + return; + } + if (!activeWorldpack.series_plan || !(activeWorldpack.volume_plans || []).length || !(activeWorldpack.arc_plans || []).length) { + authorNotice("当前 draft 还没有 longform plan,请先点击 “生成 Longform Plan”。"); + return; + } + const worldpack = structuredClone(activeWorldpack); + const volumePlans = worldpack.volume_plans || []; + const arcPlans = worldpack.arc_plans || []; + const selectedVolume = volumePlans.find((item) => item.volume_id === authorState.selectedAuthorVolumeId) || volumePlans[0]; + const selectedArc = arcPlans.find((item) => item.arc_id === authorState.selectedAuthorArcId) || arcPlans.find((item) => item.volume_id === selectedVolume?.volume_id); + worldpack.series_plan = worldpack.series_plan || {}; + worldpack.series_plan.title = dom.authorSeriesTitle?.value.trim() || worldpack.series_plan.title; + worldpack.series_plan.theme_statement = dom.authorSeriesTheme?.value.trim() || ""; + worldpack.series_plan.total_volume_target = Math.max(volumePlans.length || 1, Number(dom.authorSeriesTotalVolumes?.value || worldpack.series_plan.total_volume_target || volumePlans.length || 1)); + worldpack.series_plan.total_chapter_target = Math.max( + (volumePlans || []).reduce((sum, item) => sum + Math.max(1, Number(item.target_chapters || 1)), 0), + Number(dom.authorSeriesTotalChapters?.value || worldpack.series_plan.total_chapter_target || 1) + ); + worldpack.series_plan.target_word_count = Math.max(1000, Number(dom.authorSeriesTargetWords?.value || worldpack.series_plan.target_word_count || 1000)); + try { + worldpack.series_storyline_contract = JSON.parse(dom.authorStorylineContract?.value || "{}"); + worldpack.character_memory_profiles = JSON.parse(dom.authorCharacterMemoryProfiles?.value || "{}"); + worldpack.steering_guardrails = JSON.parse(dom.authorSteeringGuardrails?.value || "{}"); + } catch (error) { + authorNotice("Longform contract JSON 解析失败,请检查 Storyline / Character Memory / Guardrails。", "error"); + return; + } + if (selectedVolume) { + selectedVolume.title = dom.authorVolumeTitle?.value.trim() || selectedVolume.title; + selectedVolume.goal = dom.authorVolumeGoal?.value.trim() || ""; + selectedVolume.target_chapters = Math.max(1, Number(dom.authorVolumeTargetChapters?.value || selectedVolume.target_chapters || 1)); + selectedVolume.climax_definition = dom.authorVolumeClimax?.value.trim() || ""; + selectedVolume.end_state = dom.authorVolumeEndState?.value.trim() || ""; + } + if (selectedArc) { + selectedArc.title = dom.authorArcTitle?.value.trim() || selectedArc.title; + selectedArc.goal = dom.authorArcGoal?.value.trim() || ""; + selectedArc.conflict = dom.authorArcConflict?.value.trim() || ""; + selectedArc.target_chapters = Math.max(1, Number(dom.authorArcTargetChapters?.value || selectedArc.target_chapters || 1)); + selectedArc.reveal_budget = Math.max(0, Number(dom.authorArcRevealBudget?.value || selectedArc.reveal_budget || 0)); + selectedArc.payoff_targets = parseMultilineList(dom.authorArcPayoffTargets?.value || ""); + selectedArc.completion_conditions = parseMultilineList(dom.authorArcCompletionConditions?.value || ""); + selectedArc.chapter_tasks = normalizeLongformArcTasks(selectedArc, worldpack.chapter_budget_policy || {}); + const selectedTask = selectedArc.chapter_tasks.find((item) => item.chapter_task_id === authorState.selectedAuthorTaskId) || selectedArc.chapter_tasks[0]; + if (selectedTask) { + selectedTask.duty_type = dom.authorTaskDuty?.value || selectedTask.duty_type; + selectedTask.objective = dom.authorTaskObjective?.value.trim() || ""; + selectedTask.target_words = Math.max(500, Number(dom.authorTaskTargetWords?.value || selectedTask.target_words || 2000)); + selectedTask.reveal_budget = Math.max(0, Number(dom.authorTaskRevealBudget?.value || selectedTask.reveal_budget || 0)); + selectedTask.promise_actions = parseMultilineList(dom.authorTaskPromiseActions?.value || ""); + selectedTask.promise_targets = parseMultilineList(dom.authorTaskPromiseTargets?.value || ""); + selectedTask.allow_terminal = Boolean(dom.authorTaskAllowTerminal?.checked); + } + } + + try { + const draft = await api(`/v1/author/drafts/${authorState.activeDraftVersionId}`, { + method: "PUT", + body: JSON.stringify({ + worldpack, + account_id: dom.authorAccountId?.value.trim() || "web_author", + change_context: buildDraftChangeContext("longform_editor", "保存长篇规划", "chapter_task"), + }), + }); + syncAuthorRepairLoopHintFromDraft(draft, "chapter_task"); + authorState.activeDraftDetail = await api(`/v1/author/drafts/${authorState.activeDraftVersionId}`); + authorState.selectedAuthorRevisionIndex = null; + authorState.authorValidationReport = draft.validation_report || authorState.activeDraftDetail.validation_report; + await refreshAuthorSurface(); + focusAuthorPanel("diff"); + } catch (error) { + const detail = parseErrorDetail(error); + await refreshAuthorSurface(); + if (detail?.code === "author_entitlement_required") { + alertAuthorGating(detail, "保存长篇规划"); + return; + } + authorNotice(formatAuthorApiErrorMessage(error, "保存长篇规划失败,请稍后再试。"), "error"); + } +} + +async function savePromiseStateWorkbench() { + if (!authorState.activeDraftVersionId) { + authorNotice("先选择一个 draft。"); + return; + } + const promiseId = authorState.selectedAuthorPromiseId || dom.authorPromiseSelect?.value || ""; + if (!promiseId) { + authorNotice("先选择一条 Promise。"); + return; + } + const mapping = getSeriesVolumeArcPromiseMapping(); + const taskLinking = getChapterTaskSimulationLinking(); + const selectedArc = (mapping.arcs || []).find((item) => item.arc_id === authorState.selectedAuthorArcId) || null; + const selectedTask = + (taskLinking.task_links || []).find((item) => item.chapter_task_id === authorState.selectedAuthorTaskId) || null; + const selectedPromise = + (getPromiseStateWorkbench().editable_promises || []).find((item) => item.promise_id === promiseId) || null; + try { + await api(`/v1/author/drafts/${authorState.activeDraftVersionId}/promise-state`, { + method: "POST", + body: JSON.stringify({ + promise_id: promiseId, + editor_state: dom.authorPromiseState?.value || "", + notes: dom.authorPromiseNotes?.value || "", + chapter_index: Number(selectedPromise?.last_seen_chapter || selectedPromise?.first_seen_chapter || 0) || null, + chapter_task_id: selectedTask?.chapter_task_id || null, + arc_id: selectedArc?.arc_id || selectedTask?.arc_id || null, + volume_id: authorState.selectedAuthorVolumeId || selectedTask?.volume_id || null, + account_id: dom.authorAccountId?.value.trim() || "web_author", + }), + }); + authorState.activeDraftDetail = await api(`/v1/author/drafts/${authorState.activeDraftVersionId}`); + await refreshAuthorSurface(); + focusAuthorPanel("draft_detail"); + } catch (error) { + const detail = parseErrorDetail(error); + await refreshAuthorSurface(); + if (detail?.code === "author_entitlement_required") { + alertAuthorGating(detail, "保存 Promise 状态"); + return; + } + authorNotice(formatAuthorApiErrorMessage(error, "保存 Promise 状态失败,请稍后再试。"), "error"); + } +} + +async function saveContinuityOverrideWorkbench() { + if (!authorState.activeDraftVersionId) { + authorNotice("先选择一个 draft。"); + return; + } + const chapterIndex = Number(authorState.selectedAuthorContinuityChapterIndex || dom.authorContinuityChapterSelect?.value || 0); + if (!chapterIndex) { + authorNotice("先选择一个 continuity chapter。"); + return; + } + const continuityCandidate = + (getContinuityOverrideWorkbench().candidate_chapters || []).find((item) => Number(item.chapter_index || 0) === chapterIndex) || null; + const selectedTask = + (getChapterTaskSimulationLinking().task_links || []).find((item) => + (item.linked_chapters || []).some((chapter) => Number(chapter.chapter_index || 0) === chapterIndex) + ) || null; + try { + await api(`/v1/author/drafts/${authorState.activeDraftVersionId}/continuity-override`, { + method: "POST", + body: JSON.stringify({ + account_id: dom.authorAccountId?.value.trim() || "web_author", + chapter_index: chapterIndex, + override_state: dom.authorContinuityOverrideState?.value || "", + notes: dom.authorContinuityOverrideNotes?.value || "", + issue_scope: parseMultilineList(dom.authorContinuityIssueScope?.value || ""), + chapter_task_id: selectedTask?.chapter_task_id || continuityCandidate?.chapter_task_id || null, + arc_id: selectedTask?.arc_id || continuityCandidate?.arc_id || null, + volume_id: selectedTask?.volume_id || continuityCandidate?.volume_id || null, + }), + }); + authorState.activeDraftDetail = await api(`/v1/author/drafts/${authorState.activeDraftVersionId}`); + await refreshAuthorSurface(); + focusAuthorPanel("compare"); + } catch (error) { + const detail = parseErrorDetail(error); + await refreshAuthorSurface(); + if (detail?.code === "author_entitlement_required") { + alertAuthorGating(detail, "保存 Continuity Override"); + return; + } + authorNotice(formatAuthorApiErrorMessage(error, "保存 Continuity Override 失败,请稍后再试。"), "error"); + } +} + +function jumpToSelectedCompareChapter() { + const chapterIndex = Number(authorState.selectedAuthorContinuityChapterIndex || dom.authorContinuityChapterSelect?.value || 0); + if (!chapterIndex) { + authorNotice("当前没有可跳转的章节对照。"); + return; + } + jumpToAuthorChapter(chapterIndex, "compare"); +} + +function commentSelectedContinuityChapter() { + const chapterIndex = Number(authorState.selectedAuthorContinuityChapterIndex || dom.authorContinuityChapterSelect?.value || 0); + if (!chapterIndex) { + authorNotice("当前没有可评论的章节。"); + return; + } + prefillAuthorCommentAnchor("simulation", String(chapterIndex)); +} + +function jumpToSelectedPromiseChapter() { + const selectedPromise = + (getPromiseStateWorkbench().editable_promises || []).find((item) => item.promise_id === authorState.selectedAuthorPromiseId) || null; + const chapterIndex = Number(selectedPromise?.last_seen_chapter || selectedPromise?.first_seen_chapter || 0); + if (!chapterIndex) { + authorNotice("当前 Promise 还没有可跳转的 simulation chapter。"); + return; + } + jumpToAuthorChapter(chapterIndex, "simulation"); +} + +function commentSelectedPromise() { + const selectedPromise = + (getPromiseStateWorkbench().editable_promises || []).find((item) => item.promise_id === authorState.selectedAuthorPromiseId) || null; + if (!selectedPromise?.anchor?.anchor_key) { + authorNotice("当前 Promise 还没有可评论的 anchor。"); + return; + } + prefillAuthorCommentAnchor(selectedPromise.anchor.anchor_type || "simulation", String(selectedPromise.anchor.anchor_key)); +} + +let authorWorkspaceEventsBound = false; +let authorWorkspaceInitialized = false; + +function bindAuthorWorkspaceEvents() { + if (authorWorkspaceEventsBound) return; + authorWorkspaceEventsBound = true; + + dom.authorGenrePreset?.addEventListener("change", applyAuthorPresetDefaults); + dom.authorCharacterSelect?.addEventListener("change", renderCharacterEditor); + dom.authorSceneSelect?.addEventListener("change", renderSceneEditor); + dom.authorVolumeSelect?.addEventListener("change", () => { + authorState.selectedAuthorVolumeId = dom.authorVolumeSelect.value || null; + authorState.selectedAuthorArcId = null; + renderLongformWorkbench(); + }); + dom.authorArcSelect?.addEventListener("change", () => { + authorState.selectedAuthorArcId = dom.authorArcSelect.value || null; + authorState.selectedAuthorTaskId = null; + if (dom.authorTaskBulkIssues) dom.authorTaskBulkIssues.value = ""; + if (dom.authorTaskBulkNotes) dom.authorTaskBulkNotes.value = ""; + renderLongformWorkbench(); + }); + dom.authorTaskSelect?.addEventListener("change", () => { + authorState.selectedAuthorTaskId = dom.authorTaskSelect.value || null; + if (dom.authorTaskBulkIssues) dom.authorTaskBulkIssues.value = ""; + if (dom.authorTaskBulkNotes) dom.authorTaskBulkNotes.value = ""; + renderLongformWorkbench(); + }); + dom.authorTaskSplitTargets?.addEventListener("click", splitSelectedTaskPromiseTargets); + dom.authorTaskMergeObserved?.addEventListener("click", mergeObservedPromisesIntoTargets); + dom.authorTaskApplyRewrite?.addEventListener("click", applySelectedTaskRewritePrefill); + dom.authorExportRewritePatch?.addEventListener("click", exportRewritePatchPreview); + dom.authorPromiseSelect?.addEventListener("change", () => { + authorState.selectedAuthorPromiseId = dom.authorPromiseSelect.value || null; + renderPromiseLedgerWorkbench(); + }); + dom.authorContinuityChapterSelect?.addEventListener("change", () => { + authorState.selectedAuthorContinuityChapterIndex = Number(dom.authorContinuityChapterSelect.value || 0) || null; + renderContinuityDiffWorkbench(); + renderAuthorCompare(); + }); + dom.authorCreateDraft?.addEventListener("click", createDraftFromCurrentWorld); + dom.authorCreateDraftFromBrief?.addEventListener("click", createDraftFromBrief); + dom.authorRefresh?.addEventListener("click", async () => { + if (!confirmAuthorWorkDiscard("当前章节还有未保存改动,刷新创作台会重新拉取服务器数据。确认继续吗?")) { + return; + } + if (isAuthorWorkDraftDirty()) { + discardAuthorWorkDraft(); + } + await refreshAuthorSurface(); + }); + window.addEventListener("beforeunload", (event) => { + if (!isAuthorWorkDraftDirty()) return; + event.preventDefault(); + event.returnValue = ""; + }); + dom.authorAuthRegister?.addEventListener("click", async () => { + try { + await registerAuthorAuthIdentity(); + } catch (error) { + authorNotice(describeAuthError(error, "注册暂时失败,请稍后重试。"), "error"); + } + }); + dom.authorAuthLogin?.addEventListener("click", async () => { + try { + await loginAuthorAuthIdentity(); + } catch (error) { + const detail = parseErrorDetail(error) || {}; + authorNotice(describeAuthError(error, "登录暂时失败,请稍后重试。"), detail.code === "auth_email_unverified" ? "warning" : "error"); + } + }); + dom.authorAuthLogout?.addEventListener("click", logoutAuthorAuthIdentity); + dom.authorSaveStyleControls?.addEventListener("click", saveCapabilityAssets); + dom.authorSaveCharacter?.addEventListener("click", saveCharacterCard); + dom.authorSaveScene?.addEventListener("click", saveSceneBlueprint); + dom.authorBootstrapLongform?.addEventListener("click", bootstrapLongformWorkbench); + dom.authorSaveLongform?.addEventListener("click", saveLongformWorkbench); + dom.authorSavePromiseState?.addEventListener("click", savePromiseStateWorkbench); + dom.authorJumpPromiseChapter?.addEventListener("click", jumpToSelectedPromiseChapter); + dom.authorCommentPromise?.addEventListener("click", commentSelectedPromise); + dom.authorSaveContinuityOverride?.addEventListener("click", saveContinuityOverrideWorkbench); + dom.authorJumpCompareChapter?.addEventListener("click", jumpToSelectedCompareChapter); + dom.authorCommentContinuity?.addEventListener("click", commentSelectedContinuityChapter); + dom.authorTaskBulkApply?.addEventListener("click", bulkApplyTaskToSimulation); + dom.authorRunCheckpointResimulate?.addEventListener("click", runCheckpointAwareResimulate); + dom.authorRunSteeredSimulation?.addEventListener("click", runSteeredSimulation); + dom.authorClearSteering?.addEventListener("click", () => { + clearAuthorSteeringComposer(); + renderAuthorSteeringComposer(); + }); + dom.authorSaveCapabilities?.addEventListener("click", saveCapabilityAssets); + dom.authorRefreshReviewerInbox?.addEventListener("click", async () => { + await refreshAuthorReviewerInbox(); + renderAuthorReports(); + }); + dom.authorSearchReviewerInbox?.addEventListener("click", async () => { + await refreshAuthorReviewerInbox(); + renderAuthorReports(); + }); + dom.authorLoadMoreReviewerInbox?.addEventListener("click", async () => { + if (!authorState.authorReviewerInboxNextCursor) return; + await refreshAuthorReviewerInbox({ append: true, cursor: authorState.authorReviewerInboxNextCursor }); + renderAuthorReports(); + }); + dom.authorInboxReviewerId?.addEventListener("change", async () => { + await refreshAuthorReviewerInbox(); + renderAuthorReports(); + }); + dom.authorInboxStatusFilter?.addEventListener("change", async () => { + await refreshAuthorReviewerInbox(); + renderAuthorReports(); + }); + dom.authorInboxWorldVersionFilter?.addEventListener("change", async () => { + await refreshAuthorReviewerInbox(); + renderAuthorReports(); + }); + dom.authorInboxNotificationTypeFilter?.addEventListener("change", async () => { + await refreshAuthorReviewerInbox(); + renderAuthorReports(); + }); + dom.authorInboxBlockingOnly?.addEventListener("change", async () => { + await refreshAuthorReviewerInbox(); + renderAuthorReports(); + }); + dom.authorInboxSearch?.addEventListener("keydown", async (event) => { + if (event.key !== "Enter") return; + await refreshAuthorReviewerInbox(); + renderAuthorReports(); + }); + dom.authorBulkReadVisible?.addEventListener("click", async () => { + await bulkUpdateAuthorNotificationStatus("read"); + }); + dom.authorBulkArchiveVisible?.addEventListener("click", async () => { + await bulkUpdateAuthorNotificationStatus("archived"); + }); + dom.authorAddDraftWatcher?.addEventListener("click", addAuthorDraftWatcher); + dom.authorRemoveDraftWatcher?.addEventListener("click", removeAuthorDraftWatcher); + dom.authorRefreshNotificationPreferences?.addEventListener("click", async () => { + await refreshAuthorNotificationPreferences(); + renderAuthorReports(); + }); + dom.authorSaveNotificationPreference?.addEventListener("click", saveAuthorNotificationPreference); + dom.authorNotificationPrefType?.addEventListener("change", () => { + syncAuthorNotificationPreferenceInputs(); + }); + dom.authorAccountId?.addEventListener("change", () => { + if (dom.authorAuthActorId && !dom.authorAuthActorId.value.trim()) { + dom.authorAuthActorId.value = dom.authorAccountId.value.trim(); + } + }); + dom.authorApprovalReviewer?.addEventListener("change", () => { + if (dom.authorInboxReviewerId && !dom.authorInboxReviewerId.value.trim()) { + dom.authorInboxReviewerId.value = dom.authorApprovalReviewer.value.trim(); + } + }); + dom.authorCreateCommentThread?.addEventListener("click", createAuthorCommentThread); + dom.authorRequestApproval?.addEventListener("click", requestAuthorApproval); + dom.authorApproveDraft?.addEventListener("click", () => decideAuthorApproval("approved")); + dom.authorRequestChanges?.addEventListener("click", () => decideAuthorApproval("changes_requested")); +} + +function initializeAuthorWorkspaceRuntime() { + if (authorWorkspaceInitialized) return; + authorWorkspaceInitialized = true; + bindAuthorWorkspaceEvents(); + restoreAuthorAuthSession(); + renderAuthorAuthStatus(); +} + + + + return { + authorStageLabel, + focusAuthorPanel, + prefillAuthorCommentAnchor, + jumpToAuthorChapter, + activeAuthorReviewerId, + activeAuthorActorId, + activeAuthorActorRole, + currentAuthorInboxFilters, + authorCollaborationHeaders, + selectAuthorThread, + mergeAuthorReviewerInbox, + syncAuthorNotificationPreferenceInputs, + persistAuthorAuthSession, + restoreAuthorAuthSession, + renderAuthorAuthStatus, + refreshAuthorReviewerInbox, + updateAuthorThreadStatusInline, + updateAuthorNotificationStatus, + bulkUpdateAuthorNotificationStatus, + decideAuthorApprovalForWorld, + addAuthorThreadWatcher, + removeAuthorThreadWatcher, + replyToSelectedAuthorThread, + addAuthorDraftWatcher, + removeAuthorDraftWatcher, + refreshAuthorNotificationPreferences, + saveAuthorNotificationPreference, + registerAuthorAuthIdentity, + loginAuthorAuthIdentity, + hydrateAuthorAuthSession, + logoutAuthorAuthIdentity, + validateDraftVersion, + simulateDraftVersion, + submitDraftVersion, + createAuthorCommentThread, + requestAuthorApproval, + decideAuthorApproval, + runAuthorWorkflowAction, + populateAuthorBriefForm, + applyAuthorPresetDefaults, + buildAuthorBriefPayload, + getActiveDraftCharacters, + getActiveDraftScenes, + getActiveSeriesPlan, + getActiveVolumePlans, + getActiveArcPlans, + applyDraftWorldpackMutation, + resequenceArcOrders, + reorderArcWithinVolume, + reorderTaskWithinArc, + moveTaskAcrossArcs, + selectedCharacterIndex, + selectedSceneIndex, + renderAuthorDraftDetail, + renderCharacterEditor, + renderSceneEditor, + renderLongformWorkbench, + renderPromiseLedgerWorkbench, + renderSeriesVolumeArcPromiseMapping, + renderChapterTaskSimulationLinking, + renderRewritePatchPreview, + renderSimulationDiffCheckpoint, + exportRewritePatchPreview, + runCheckpointAwareResimulate, + splitSelectedTaskPromiseTargets, + mergeObservedPromisesIntoTargets, + applySelectedTaskRewritePrefill, + bulkApplyTaskToSimulation, + openAuthorCharacterAsset, + openAuthorSceneAsset, + openAuthorTaskAsset, + openAuthorPriorityAsset, + runSteeredSimulation, + prefillAuthorSteeringFromChoice, + renderContinuityDiffWorkbench, + parseMultilineList, + splitPromiseTargetList, + normalizeLongformArcTasks, + formatMultilineList, + parseLabelMap, + formatLabelMap, + parseSceneHooks, + formatSceneHooks, + renderStylePacingHookControls, + applyStylePacingHookControls, + buildSimulationDiffSummary, + renderAuthorRevisionPanels, + renderAuthorSteeringComposer, + renderAuthorCreativeCockpit, + renderAuthorDrafts, + renderAuthorWorkflow, + renderAuthorReports, + renderAuthorCompare, + renderAuthorCollaboration, + refreshAuthorSurface, + createDraftFromCurrentWorld, + createDraftFromBrief, + bindAuthorWorkspaceEvents, + initializeAuthorWorkspaceRuntime, + saveCapabilityAssets, + saveCharacterCard, + saveSceneBlueprint, + bootstrapLongformWorkbench, + saveLongformWorkbench, + savePromiseStateWorkbench, + saveContinuityOverrideWorkbench, + jumpToSelectedCompareChapter, + commentSelectedContinuityChapter, + jumpToSelectedPromiseChapter, + commentSelectedPromise + }; +})(); diff --git a/src/narrativeos/web/customer_dom.js b/src/narrativeos/web/customer_dom.js new file mode 100644 index 0000000..5109bcd --- /dev/null +++ b/src/narrativeos/web/customer_dom.js @@ -0,0 +1,50 @@ +// Customer workspace scoped DOM registry. + +var CustomerDOM = (() => ({ + customerShell: DOMShared.query("#customer-shell"), + customerCampaignEditor: DOMShared.query("#customer-campaign-editor"), + customerCampaignId: DOMShared.query("#customer-campaign-id"), + customerCampaignTitle: DOMShared.query("#customer-campaign-title"), + customerCampaignTargetIcp: DOMShared.query("#customer-campaign-target-icp"), + customerCampaignCta: DOMShared.query("#customer-campaign-cta"), + customerCampaignDisclosure: DOMShared.query("#customer-campaign-disclosure"), + customerCampaignChannels: DOMShared.query("#customer-campaign-channels"), + customerCampaignPartners: DOMShared.query("#customer-campaign-partners"), + customerCampaignProofPoints: DOMShared.query("#customer-campaign-proof-points"), + customerCampaignProofUrls: DOMShared.query("#customer-campaign-proof-urls"), + customerCampaignArtifactRefs: DOMShared.query("#customer-campaign-artifact-refs"), + customerCampaignSave: DOMShared.query("#customer-campaign-save"), + customerCampaignSubmit: DOMShared.query("#customer-campaign-submit"), + customerCampaignList: DOMShared.query("#customer-campaign-list"), + customerDisputeBillableEventId: DOMShared.query("#customer-dispute-billable-event-id"), + customerDisputeReason: DOMShared.query("#customer-dispute-reason"), + customerDisputeAmount: DOMShared.query("#customer-dispute-amount"), + customerDisputeNote: DOMShared.query("#customer-dispute-note"), + customerDisputeSubmit: DOMShared.query("#customer-dispute-submit"), + customerDisputeList: DOMShared.query("#customer-dispute-list"), + customerSupportSubject: DOMShared.query("#customer-support-subject"), + customerSupportDescription: DOMShared.query("#customer-support-description"), + customerSupportPriority: DOMShared.query("#customer-support-priority"), + customerSupportSubmit: DOMShared.query("#customer-support-submit"), + customerSupportList: DOMShared.query("#customer-support-list"), + customerCampaignSummary: DOMShared.query("#customer-campaign-summary"), + customerAccountSummary: DOMShared.query("#customer-account-summary"), + customerPlanSummary: DOMShared.query("#customer-plan-summary"), + customerBillingSummary: DOMShared.query("#customer-billing-summary"), + customerLimitSummary: DOMShared.query("#customer-limit-summary"), + customerQualitySummary: DOMShared.query("#customer-quality-summary"), + customerGroundednessSummary: DOMShared.query("#customer-groundedness-summary"), + customerPartnerPerformance: DOMShared.query("#customer-partner-performance"), + customerReceiptSummary: DOMShared.query("#customer-receipt-summary"), + customerHandoffSummary: DOMShared.query("#customer-handoff-summary"), + customerDisputeSummary: DOMShared.query("#customer-dispute-summary"), + customerSupportSummary: DOMShared.query("#customer-support-summary"), + customerLatestTraces: DOMShared.query("#customer-latest-traces"), + customerExportSummary: DOMShared.query("#customer-export-summary"), + customerWorkspaceNote: DOMShared.query("#customer-workspace-note"), + customerRefresh: DOMShared.query("#customer-refresh"), + customerExportJson: DOMShared.query("#customer-export-json"), + customerExportCsv: DOMShared.query("#customer-export-csv"), + customerExportPdf: DOMShared.query("#customer-export-pdf"), + customerExportInvoiceCsv: DOMShared.query("#customer-export-invoice-csv"), +}))(); diff --git a/src/narrativeos/web/customer_workspace.js b/src/narrativeos/web/customer_workspace.js new file mode 100644 index 0000000..1325d0a --- /dev/null +++ b/src/narrativeos/web/customer_workspace.js @@ -0,0 +1,644 @@ +// Customer-facing reporting workspace runtime. + +var CustomerWorkspaceRuntime = (() => { + const dom = CustomerDOM; + const { + api, + clearNode, + createListCard, + downloadBase64File, + downloadJsonFile, + downloadTextFile, + formatPercent, + parseErrorDetail, + reportUiMessage, + } = UIShared; + + function _identity() { + return authorState.authorAuthSession?.identity || null; + } + + function _token() { + return authorState.authorAuthSession?.accessToken || null; + } + + function _canViewCustomerWorkspace() { + const role = String(_identity()?.actor_role || "").trim(); + return ["customer", "reviewer", "ops", "admin"].includes(role); + } + + function _headers() { + return { + Authorization: `Bearer ${_token()}`, + }; + } + + function _renderEmpty(message) { + [ + dom.customerCampaignEditor, + dom.customerCampaignList, + dom.customerCampaignSummary, + dom.customerAccountSummary, + dom.customerPlanSummary, + dom.customerBillingSummary, + dom.customerLimitSummary, + dom.customerPartnerPerformance, + dom.customerQualitySummary, + dom.customerGroundednessSummary, + dom.customerReceiptSummary, + dom.customerHandoffSummary, + dom.customerDisputeSummary, + dom.customerDisputeList, + dom.customerSupportSummary, + dom.customerSupportList, + dom.customerLatestTraces, + dom.customerExportSummary, + ].forEach((node) => clearNode(node, message)); + } + + function _parseLines(value) { + return String(value || "") + .split(/\n+/) + .map((item) => item.trim()) + .filter(Boolean); + } + + function _parseCommaList(value) { + return String(value || "") + .split(/[\s,,]+/) + .map((item) => item.trim()) + .filter(Boolean); + } + + function _campaignFormPayload() { + return { + campaign_id: (dom.customerCampaignId?.value || "").trim() || null, + title: (dom.customerCampaignTitle?.value || "").trim(), + target_icp_vertical: (dom.customerCampaignTargetIcp?.value || "").trim(), + cta_text: (dom.customerCampaignCta?.value || "").trim(), + disclosure_text: (dom.customerCampaignDisclosure?.value || "").trim(), + selected_channels: _parseCommaList(dom.customerCampaignChannels?.value || ""), + selected_partner_refs: _parseCommaList(dom.customerCampaignPartners?.value || ""), + proof_points: _parseLines(dom.customerCampaignProofPoints?.value || ""), + proof_source_urls: _parseLines(dom.customerCampaignProofUrls?.value || ""), + proof_artifact_refs: _parseLines(dom.customerCampaignArtifactRefs?.value || ""), + }; + } + + function loadCampaignIntoEditor(campaignDetail) { + const campaign = campaignDetail?.campaign || campaignDetail || {}; + const proofBundle = (campaignDetail?.proof_bundles || [])[0] || {}; + if (dom.customerCampaignId) dom.customerCampaignId.value = campaign.campaign_id || ""; + if (dom.customerCampaignTitle) dom.customerCampaignTitle.value = campaign.title || ""; + if (dom.customerCampaignTargetIcp) dom.customerCampaignTargetIcp.value = campaign.target_icp_vertical || ""; + if (dom.customerCampaignCta) dom.customerCampaignCta.value = campaign.cta_text || ""; + if (dom.customerCampaignDisclosure) dom.customerCampaignDisclosure.value = campaign.disclosure_text || ""; + if (dom.customerCampaignChannels) dom.customerCampaignChannels.value = (campaign.selected_channels_json || []).join(", "); + if (dom.customerCampaignPartners) dom.customerCampaignPartners.value = (campaign.selected_partner_refs_json || []).join(", "); + if (dom.customerCampaignProofPoints) dom.customerCampaignProofPoints.value = (proofBundle.proof_points_json || []).join("\n"); + if (dom.customerCampaignProofUrls) dom.customerCampaignProofUrls.value = (proofBundle.source_urls_json || []).join("\n"); + if (dom.customerCampaignArtifactRefs) dom.customerCampaignArtifactRefs.value = (proofBundle.artifact_refs_json || []).join("\n"); + customerState.selectedCampaignId = campaign.campaign_id || null; + } + + function _accountScore(lifecycle) { + const status = String(lifecycle?.status || "unknown"); + if (status === "active") return "active"; + if (status === "trial") return "trial"; + if (status === "renewal_due") return "renewal_due"; + return status; + } + + function _limitBody(limitPosture) { + return [ + `席位 ${Number(limitPosture?.seat_count || 0)}/${Number(limitPosture?.seat_limit || 0)}`, + `工作区 ${Number(limitPosture?.workspace_count || 0)}/${Number(limitPosture?.workspace_limit || 0)}`, + `Campaign ${Number(limitPosture?.campaign_count || 0)}/${Number(limitPosture?.campaign_limit || 0)}`, + `超限 ${limitPosture?.is_over_limit ? "是" : "否"}`, + ].join("\n"); + } + + function _lineAmount(lineItems, metricType) { + const item = (lineItems || []).find((entry) => entry.metric_type === metricType); + return item ? item.line_amount_usd : 0; + } + + function renderCustomerSurface(payload) { + customerState.workspacePayload = payload; + const customer = payload?.customer_account || {}; + const plan = payload?.plan || {}; + const billingProfile = payload?.billing_profile || {}; + const lifecycle = payload?.lifecycle_summary || {}; + const limits = payload?.limit_posture || {}; + const campaignSummary = payload?.campaign_summary || {}; + const qualitySummary = payload?.quality_summary || {}; + const groundednessSummary = payload?.groundedness_summary || {}; + const receiptSummary = payload?.receipt_summary || {}; + const partnerPerformance = payload?.channel_partner_performance || {}; + const handoffSummary = payload?.handoff_conversion_summary || {}; + const disputeSummary = payload?.dispute_summary || {}; + const disputes = payload?.disputes || []; + const supportSummary = payload?.support_summary || {}; + const supportCases = payload?.support_cases || []; + const renewalSummary = payload?.renewal_summary || {}; + const dunningSummary = payload?.dunning_summary || {}; + const pilotConversionSummary = payload?.pilot_conversion_summary || {}; + const expansionSummary = payload?.expansion_summary || {}; + const churnRiskSummary = payload?.churn_risk_summary || {}; + const invoicePreview = payload?.invoice_preview || {}; + const lineItems = handoffSummary?.line_items || []; + const latestTraces = payload?.linked_traces || []; + const campaigns = payload?.campaign_details || []; + + clearNode(dom.customerCampaignEditor); + clearNode(dom.customerCampaignList); + clearNode(dom.customerCampaignSummary); + clearNode(dom.customerAccountSummary); + clearNode(dom.customerPlanSummary); + clearNode(dom.customerBillingSummary); + clearNode(dom.customerLimitSummary); + clearNode(dom.customerPartnerPerformance); + clearNode(dom.customerQualitySummary); + clearNode(dom.customerGroundednessSummary); + clearNode(dom.customerReceiptSummary); + clearNode(dom.customerHandoffSummary); + clearNode(dom.customerDisputeSummary); + clearNode(dom.customerDisputeList); + clearNode(dom.customerSupportSummary); + clearNode(dom.customerSupportList); + clearNode(dom.customerLatestTraces); + clearNode(dom.customerExportSummary); + + dom.customerCampaignSummary?.appendChild( + createListCard({ + title: "Campaign 摘要", + score: campaignSummary.activation_status || "campaign_workflow_pending", + body: [ + `campaign_count ${campaignSummary.campaign_count || 0}`, + `campaign_limit ${campaignSummary.campaign_limit || 0}`, + `status_counts ${Object.entries(campaignSummary.status_counts || {}).map(([key, value]) => `${key}:${value}`).join(" / ") || "-"}`, + `requested_campaign_id ${campaignSummary.requested_campaign_id || "-"}`, + ].join("\n"), + }) + ); + + dom.customerCampaignEditor?.appendChild( + createListCard({ + title: customerState.selectedCampaignId ? `当前编辑 ${customerState.selectedCampaignId}` : "Campaign 编辑器", + score: customerState.selectedCampaignId ? "editing" : "new_draft", + body: customerState.selectedCampaignId + ? "修改当前 campaign 后可继续保存 Draft,或直接再次提交送审。" + : "填写标题、ICP、CTA、proof bundle、disclosure 和渠道后,可以先保存 Draft,再提交送审。", + }) + ); + if (!campaigns.length) { + clearNode(dom.customerCampaignList, "当前还没有 campaign。先在上面的表单里保存一个 Draft。"); + } else { + campaigns.forEach((detail) => { + const campaign = detail?.campaign || {}; + const card = createListCard({ + title: campaign.title || campaign.campaign_id || "campaign", + score: campaign.activation_status || "draft", + body: [ + `campaign_id ${campaign.campaign_id || "-"}`, + `target_icp_vertical ${campaign.target_icp_vertical || "-"}`, + `channels ${(campaign.selected_channels_json || []).join(" / ") || "-"}`, + ].join("\n"), + active: customerState.selectedCampaignId === campaign.campaign_id, + }); + card.addEventListener("click", () => { + loadCampaignIntoEditor(detail); + renderCustomerSurface(customerState.workspacePayload || payload); + }); + dom.customerCampaignList?.appendChild(card); + }); + } + + dom.customerAccountSummary?.appendChild( + createListCard({ + title: customer.display_name || customer.account_id || "客户账户", + score: _accountScore(lifecycle), + body: [ + `customer_account_id ${customer.customer_account_id || "-"}`, + `account_id ${customer.account_id || "-"}`, + `plan ${plan.display_name || plan.plan_id || "-"}`, + `renewal_due_at ${lifecycle.renewal_due_at || "-"}`, + `renewal_risk ${lifecycle.renewal_risk || "-"}`, + ].join("\n"), + }) + ); + + dom.customerAccountSummary?.appendChild( + createListCard({ + title: "续费 / 风险自动化", + score: renewalSummary.status || "stable", + body: [ + `renewal_due_at ${renewalSummary.renewal_due_at || "-"}`, + `renewal_risk ${renewalSummary.renewal_risk || "-"}`, + `churn_risk ${churnRiskSummary.risk_level || "-"} / ${churnRiskSummary.status || "-"}`, + `open_disputes ${churnRiskSummary.open_disputes || 0} · open_support_cases ${churnRiskSummary.open_support_cases || 0}`, + ].join("\n"), + }) + ); + + dom.customerPlanSummary?.appendChild( + createListCard({ + title: "套餐与定价", + score: plan.display_name || plan.plan_id || "-", + body: [ + `subscription_tier ${plan.subscription_tier || "-"}`, + `monthly_price_usd ${plan.monthly_price_usd ?? "-"}`, + `invoice_due_usd ${invoicePreview.total_due_usd ?? 0}`, + `credits_applied_usd ${invoicePreview.credits_applied_usd ?? 0}`, + ].join("\n"), + }) + ); + + dom.customerPlanSummary?.appendChild( + createListCard({ + title: "Pilot Conversion / Expansion", + score: pilotConversionSummary.status || "watch", + body: [ + `validated_billable_count ${pilotConversionSummary.validated_billable_count || 0}`, + `active_campaign_count ${pilotConversionSummary.active_campaign_count || 0}`, + `upgrade_recommendation ${expansionSummary.recommended_plan_id || "-"}`, + `expansion_status ${expansionSummary.status || "-"}`, + ].join("\n"), + }) + ); + + dom.customerBillingSummary?.appendChild( + createListCard({ + title: "结算资料", + score: billingProfile.status || "not_configured", + body: [ + `provider ${billingProfile.provider || "-"}`, + `invoice_email ${billingProfile.invoice_email || "-"}`, + `legal_name ${billingProfile.legal_name || "-"}`, + `billing_country ${billingProfile.billing_country || "-"}`, + `tax_status ${billingProfile.tax_status || "-"}`, + ].join("\n"), + }) + ); + + dom.customerBillingSummary?.appendChild( + createListCard({ + title: "Dunning / 催缴", + score: dunningSummary.status || "clear", + body: [ + `current_step ${dunningSummary.current_step || "-"}`, + `invoice_id ${dunningSummary.invoice_id || "-"}`, + `invoice_due_usd ${dunningSummary.invoice_due_usd ?? 0}`, + `retry_count ${dunningSummary.retry_count || 0}`, + ].join("\n"), + }) + ); + + dom.customerLimitSummary?.appendChild( + createListCard({ + title: "账户限额", + score: limits.is_over_limit ? "over_limit" : "within_limit", + body: _limitBody(limits), + }) + ); + + dom.customerLimitSummary?.appendChild( + createListCard({ + title: "升级建议", + score: expansionSummary.status || "clear", + body: [ + `trigger_type ${expansionSummary.trigger_type || "-"}`, + `active_overage_flag_count ${expansionSummary.active_overage_flag_count || 0}`, + `invoice_due_usd ${expansionSummary.invoice_due_usd ?? 0}`, + `recommended_plan_id ${expansionSummary.recommended_plan_id || "-"}`, + ].join("\n"), + }) + ); + + dom.customerQualitySummary?.appendChild( + createListCard({ + title: "质量摘要", + score: `${qualitySummary.event_count || 0} events`, + body: [ + `open_review_case_count ${qualitySummary.open_review_case_count || 0}`, + `blocked_event_count ${qualitySummary.blocked_event_count || 0}`, + `review_required_event_count ${qualitySummary.review_required_event_count || 0}`, + `top_reason_codes ${((qualitySummary.top_reason_codes || []).map((item) => item.reason_code).join(" / ")) || "-"}`, + ].join("\n"), + }) + ); + + dom.customerPartnerPerformance?.appendChild( + createListCard({ + title: "Channel / Partner Performance", + score: `${Object.keys(partnerPerformance.partner_mix || {}).length} partner states`, + body: [ + `channel_mix ${Object.entries(partnerPerformance.channel_mix || {}).map(([key, value]) => `${key}:${value}`).join(" / ") || "-"}`, + `partner_mix ${Object.entries(partnerPerformance.partner_mix || {}).map(([key, value]) => `${key}:${value}`).join(" / ") || "-"}`, + `allowlisted_channels ${(partnerPerformance.allowlisted_channels || []).join(" / ") || "-"}`, + ].join("\n"), + }) + ); + + dom.customerGroundednessSummary?.appendChild( + createListCard({ + title: "Groundedness 摘要", + score: formatPercent(groundednessSummary.pass_rate || 0), + body: [ + `weak_count ${groundednessSummary.weak_count || 0}`, + `failed_count ${groundednessSummary.failed_count || 0}`, + `unsupported_claim_count ${groundednessSummary.unsupported_claim_count || 0}`, + ].join("\n"), + }) + ); + + dom.customerReceiptSummary?.appendChild( + createListCard({ + title: "回执摘要", + score: `${receiptSummary.receipt_count || 0} receipts`, + body: [ + `incident_count ${receiptSummary.incident_count || 0}`, + `providers ${Object.keys(receiptSummary.by_provider || {}).join(" / ") || "-"}`, + `surfaces ${Object.keys(receiptSummary.by_surface || {}).join(" / ") || "-"}`, + ].join("\n"), + }) + ); + + dom.customerHandoffSummary?.appendChild( + createListCard({ + title: "Presented / Handoff / Conversion", + score: `${handoffSummary.validated_conversion_count || 0} conversions`, + body: [ + `validated_presented ${handoffSummary.validated_presented_count || 0} · usd ${_lineAmount(lineItems, "validated_presented")}`, + `validated_handoff ${handoffSummary.validated_handoff_count || 0} · usd ${_lineAmount(lineItems, "validated_handoff")}`, + `validated_conversion ${handoffSummary.validated_conversion_count || 0} · usd ${_lineAmount(lineItems, "validated_conversion")}`, + ].join("\n"), + }) + ); + + dom.customerDisputeSummary?.appendChild( + createListCard({ + title: "Dispute 摘要", + score: `${disputeSummary.dispute_count || 0} disputes`, + body: `status_counts ${Object.entries(disputeSummary.status_counts || {}).map(([key, value]) => `${key}:${value}`).join(" / ") || "-"}`, + }) + ); + + if (!disputes.length) { + clearNode(dom.customerDisputeList, "当前没有 disputes。"); + } else { + disputes.forEach((item) => { + dom.customerDisputeList?.appendChild( + createListCard({ + title: item.dispute_reason_code || item.dispute_id || "dispute", + score: item.status || "-", + body: [ + `billable_event_id ${item.billable_event_id || "-"}`, + `requested_amount_usd ${item.requested_amount_usd ?? 0}`, + `resolved_amount_usd ${item.resolved_amount_usd ?? 0}`, + ].join("\n"), + }) + ); + }); + } + + dom.customerSupportSummary?.appendChild( + createListCard({ + title: "Support 摘要", + score: `${supportSummary.case_count || 0} cases`, + body: `status_counts ${Object.entries(supportSummary.status_counts || {}).map(([key, value]) => `${key}:${value}`).join(" / ") || "-"}`, + }) + ); + + if (!supportCases.length) { + clearNode(dom.customerSupportList, "当前没有 support cases。"); + } else { + supportCases.forEach((item) => { + dom.customerSupportList?.appendChild( + createListCard({ + title: item.subject || item.support_case_id || "support_case", + score: item.status || "-", + body: [ + `case_type ${item.case_type || "-"}`, + `priority ${item.priority || "-"}`, + `billable_event_id ${item.billable_event_id || "-"}`, + ].join("\n"), + }) + ); + }); + } + + if (!latestTraces.length) { + clearNode(dom.customerLatestTraces, "当前还没有可展示的质量 trace。"); + } else { + latestTraces.forEach((item) => { + dom.customerLatestTraces?.appendChild( + createListCard({ + title: item.trace_id || "trace", + score: item.status || "-", + body: [ + `surface ${item.source_surface || "-"}`, + `overall_score ${item.overall_score ?? "-"}`, + `grounding_status ${item.grounding_status || "-"}`, + ].join("\n"), + }) + ); + }); + } + + dom.customerExportSummary?.appendChild( + createListCard({ + title: "导出", + score: "export_ready", + body: [ + `available ${((payload.exports || {}).available_reports || []).join(" / ")}`, + `invoice_due_usd ${invoicePreview.total_due_usd ?? 0}`, + ].join("\n"), + }) + ); + + if (dom.customerWorkspaceNote) { + dom.customerWorkspaceNote.textContent = "这个工作台只展示客户安全字段:账户、续费 / 催缴 / 升级建议、计费、质量摘要、groundedness、回执和 handoff / conversion,不暴露 Ops 原始审阅面板。"; + } + } + + async function refreshCustomerSurface() { + if (!_canViewCustomerWorkspace()) { + _renderEmpty("客户工作台仅对 customer / reviewer / ops / admin 开放。"); + return; + } + if (!_token()) { + _renderEmpty("登录后,这里会显示客户账户、质量摘要、回执和 invoice preview。"); + return; + } + try { + const payload = await api("/v1/customer/workspace", { + headers: _headers(), + }); + if (!customerState.selectedCampaignId && payload?.campaign_details?.length) { + loadCampaignIntoEditor(payload.campaign_details[0]); + } + renderCustomerSurface(payload); + } catch (error) { + customerState.workspacePayload = null; + const detail = parseErrorDetail(error); + _renderEmpty("客户工作台暂时无法加载,请先确认账号角色和账户数据。"); + reportUiMessage(`客户工作台刷新失败:${detail?.reason || error.message}`, "warning"); + } + } + + async function saveCustomerCampaignDraft() { + if (!_token()) { + reportUiMessage("请先登录后再保存 campaign。", "warning"); + return; + } + try { + const payload = await api("/v1/customer/campaigns", { + method: "POST", + headers: _headers(), + body: JSON.stringify(_campaignFormPayload()), + }); + loadCampaignIntoEditor(payload); + reportUiMessage("Campaign draft 已保存。", "success"); + await refreshCustomerSurface(); + } catch (error) { + reportUiMessage(`保存 campaign 失败:${error.message}`, "error"); + } + } + + async function submitCustomerCampaignReview() { + if (!_token()) { + reportUiMessage("请先登录后再提交送审。", "warning"); + return; + } + const campaignId = (dom.customerCampaignId?.value || "").trim(); + if (!campaignId) { + reportUiMessage("请先保存 Draft,再提交送审。", "warning"); + return; + } + try { + await api(`/v1/customer/campaigns/${encodeURIComponent(campaignId)}/submit`, { + method: "POST", + headers: _headers(), + body: JSON.stringify({}), + }); + reportUiMessage("Campaign 已提交送审,Ops review hub 会看到这条激活申请。", "success"); + await refreshCustomerSurface(); + } catch (error) { + reportUiMessage(`提交送审失败:${error.message}`, "error"); + } + } + + async function submitCustomerDispute() { + if (!_token()) { + reportUiMessage("请先登录后再提交 dispute。", "warning"); + return; + } + try { + await api("/v1/customer/disputes", { + method: "POST", + headers: _headers(), + body: JSON.stringify({ + billable_event_id: (dom.customerDisputeBillableEventId?.value || "").trim() || null, + dispute_reason_code: (dom.customerDisputeReason?.value || "").trim(), + requested_amount_usd: Number(dom.customerDisputeAmount?.value || 0), + note: (dom.customerDisputeNote?.value || "").trim() || null, + }), + }); + reportUiMessage("Dispute 已提交。", "success"); + await refreshCustomerSurface(); + } catch (error) { + reportUiMessage(`提交 dispute 失败:${error.message}`, "error"); + } + } + + async function submitCustomerSupportCase() { + if (!_token()) { + reportUiMessage("请先登录后再提交 support case。", "warning"); + return; + } + try { + await api("/v1/customer/support", { + method: "POST", + headers: _headers(), + body: JSON.stringify({ + subject: (dom.customerSupportSubject?.value || "").trim(), + description: (dom.customerSupportDescription?.value || "").trim(), + priority: (dom.customerSupportPriority?.value || "medium").trim() || "medium", + }), + }); + reportUiMessage("Support case 已提交。", "success"); + await refreshCustomerSurface(); + } catch (error) { + reportUiMessage(`提交 support case 失败:${error.message}`, "error"); + } + } + + async function exportCustomerWorkspaceReport(reportType) { + if (!_token()) { + reportUiMessage("请先登录后再导出客户报告。", "warning"); + return; + } + try { + const payload = await api(`/v1/customer/exports/${encodeURIComponent(reportType)}`, { + headers: _headers(), + }); + if (payload.content_type === "application/json") { + downloadJsonFile(payload.filename || `${reportType}.json`, payload.content || {}); + } else if (payload.content_base64) { + downloadBase64File(payload.filename || `${reportType}.bin`, payload.content_base64, payload.content_type || "application/octet-stream"); + } else { + downloadTextFile(payload.filename || `${reportType}.txt`, payload.content || "", payload.content_type || "text/plain"); + } + reportUiMessage(`已导出 ${payload.filename || reportType}。`, "success"); + } catch (error) { + reportUiMessage(`导出失败:${error.message}`, "error"); + } + } + + function bindCustomerWorkspaceEvents() { + dom.customerRefresh?.addEventListener("click", async () => { + await refreshCustomerSurface(); + }); + dom.customerExportJson?.addEventListener("click", async () => { + await exportCustomerWorkspaceReport("workspace_json"); + }); + dom.customerExportCsv?.addEventListener("click", async () => { + await exportCustomerWorkspaceReport("workspace_csv"); + }); + dom.customerExportPdf?.addEventListener("click", async () => { + await exportCustomerWorkspaceReport("workspace_pdf"); + }); + dom.customerExportInvoiceCsv?.addEventListener("click", async () => { + await exportCustomerWorkspaceReport("invoice_csv"); + }); + dom.customerCampaignSave?.addEventListener("click", async () => { + await saveCustomerCampaignDraft(); + }); + dom.customerCampaignSubmit?.addEventListener("click", async () => { + await submitCustomerCampaignReview(); + }); + dom.customerDisputeSubmit?.addEventListener("click", async () => { + await submitCustomerDispute(); + }); + dom.customerSupportSubmit?.addEventListener("click", async () => { + await submitCustomerSupportCase(); + }); + } + + function initializeCustomerWorkspaceRuntime() { + bindCustomerWorkspaceEvents(); + } + + return { + initializeCustomerWorkspaceRuntime, + refreshCustomerSurface, + renderCustomerSurface, + exportCustomerWorkspaceReport, + loadCampaignIntoEditor, + saveCustomerCampaignDraft, + submitCustomerCampaignReview, + submitCustomerDispute, + submitCustomerSupportCase, + }; +})(); diff --git a/src/narrativeos/web/dom_shared.js b/src/narrativeos/web/dom_shared.js new file mode 100644 index 0000000..e1633d6 --- /dev/null +++ b/src/narrativeos/web/dom_shared.js @@ -0,0 +1,45 @@ +// Shared DOM query helpers so each domain-specific DOM runtime can produce safe nodes directly. + +var DOMShared = (() => { + const NULL_NODE = { + innerHTML: "", + textContent: "", + value: "", + checked: false, + disabled: false, + dataset: {}, + style: {}, + classList: { + add() {}, + remove() {}, + toggle() { return false; }, + contains() { return false; }, + }, + addEventListener() {}, + appendChild() {}, + prepend() {}, + remove() {}, + scrollIntoView() {}, + setAttribute() {}, + removeAttribute() {}, + focus() {}, + blur() {}, + querySelector() { return NULL_NODE; }, + querySelectorAll() { return []; }, + closest() { return null; }, + }; + + function query(selector) { + return document.querySelector(selector) || NULL_NODE; + } + + function queryAll(selector) { + return [...document.querySelectorAll(selector)]; + } + + return { + NULL_NODE, + query, + queryAll, + }; +})(); diff --git a/src/narrativeos/web/index.html b/src/narrativeos/web/index.html index dd389c0..9f81d98 100644 --- a/src/narrativeos/web/index.html +++ b/src/narrativeos/web/index.html @@ -5,65 +5,141 @@ NarrativeOS Studio - + -
+ +
-
-

NarrativeOS

-

把一句心意,推进成一条命运。

-

- 先挑一个世界,再写下一句你此刻真正想做的事。NarrativeOS 会把它变成下一幕剧情、图文画面和命运走向。 -

-
+
+
+

NarrativeOS

+

把一句心意,推进成一条命运。

+

+ 让读者更顺地进入故事,让作者与运营更快找到下一步。新的产品壳层会按任务流组织,而不是把全部能力一次性推到眼前。 +

+
-
-
- 产品视图 -
- - - +
+
+ 产品视图 +
+ + + + +
+ +
+ 环境与状态 +
+ +
+
+ 服务 + 检测中 +
+
+ 当前区域 + 未登录 +
+
+ +

先进入书架,再开始一段故事;需要时再切换阅读视图。

+
+
+
+
+ +
+
+

阅读区域

+

先从书架进入一段旅程,再切到阅读、图文或幕后视图。

+
+
+
+ + +
+
+ +
-
- 体验视角 -
- - - +
+
+
+
+

统一登录

+

登录后,进入你的工作区。

+

+ 新注册账号默认是普通用户。审阅权限由管理员在后台分配,拥有权限的账号登录后会自动看到可审阅内容。 +

+
+
+

新账号默认进入阅读与创作。

+

审阅权限不在这里选择,由管理员后台分配。

+

同一个账号登录后,会自动展开它实际拥有的能力。

-
-
-
-

选择世界

-

挑一个命题和气味完全不同的世界。

-
+
+ + + + + + +
+ + +
- +

注册会默认创建普通用户账号;如果管理员后续赋予审阅权限,登录时会自动进入对应能力。

+
登录后,这里会说明你当前可以进入哪些区域。
+
+
-
-
-
-

继续旅程

-

回到你上一次停下来的那一幕。

-
+ + +
+
+
+

阅读首页

+

先回到你的书架,再决定这次从哪条命运线切入。

+

+ 入口只保留三件事:继续上次旅程、浏览世界、从当前世界开始新旅程。读者不需要先看后台控制项,先进入故事就够了。 +

+
+ 当前上下文 +

正在整理你可继续的旅程、当前世界和 reader 身份。

+
+
+ + + +
-
-
- 当前状态 +
-
- 服务 - 检测中 -
回合 - @@ -76,10 +152,46 @@

把一句心意,推进成一条命运。

旅程 未创建
+
+ 当前账号 + 游客 +
- + +
+
+
+
+

浏览世界

+

先看命题、气味与准入层级,再决定是否进入。

+
+
+ +
+ +
+
+
+

继续旅程

+

优先回到你停下来的那一幕,而不是重新找入口。

+
+
+
+
+ +
+
+
+

我的作品

+

把你自己创作出的章节像读书一样单独摊开看,不必回到编辑表单里找正文。

+
+
+
+
+
+
+ +
-
+
-

Story Feed

+

章节正文

章节会按时间顺序落在这里

+ Story Feed

旧章会保留在上方,新章会继续往下接。

载入 world 并执行一步后,这里会按时间顺序出现连续章节。
+
+
+

阅读反馈

+

这一章对你有用吗

+
+ + + + +
+ + +
+
@@ -300,9 +475,16 @@

图文版本将在这里展开

-
- 当故事开始流动,这里会出现一句最能代表这一幕的引句。 -
+
+

前情提要

+

当你进入章节后,这里会先把上一章到这一章之间真正重要的变化说清楚。

+
+
+

本章引句

+
+ 当故事开始流动,这里会出现一句最能代表这一幕的引句。 +
+
-
- 这里会显示这一幕最值得抓住的三个节拍。 -
-
- 这里会显示画面中的气味、动作和情绪提示。 -
-
- 这里会显示图文版本对应的 prose。 -
+
+

章节正文

+
+ 这里会显示图文版本对应的 prose。 +
+
+
+

关键节拍

+
+ 这里会显示这一幕最值得抓住的三个节拍。 +
+
+
+

情绪与画面标签

+
+ 这里会显示画面中的气味、动作和情绪提示。 +
+
@@ -339,8 +530,13 @@

图文时间线

+ + - - - - + + - - - + + + - - - + + +
- + +
+
+
+

创作台

+

作品稿编辑器 / 创作配置 / 修稿桥

+
+
+
这里会显示当前作品稿、章节编辑器和创作配置层摘要。
+

Character Cards

角色卡编辑

+
选择角色后,这里会先显示当前角色摘要。
- + - + - + - + - + - + - +
+
-
+
-

Scene Blueprints

+

场景蓝图

场景蓝图编辑

+
选择场景后,这里会先显示当前场景摘要。
- + - + - + - +
+
+
+

长篇规划

+

系列 / 分卷 / 弧线编辑

+
+
这里会先显示当前规划对象、计划来源和建议下一步。
+
这里会显示当前 draft 的长篇规划状态和计划来源。
+ + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + +

拖拽重排弧线

+
这里会显示按分卷组织的弧线看板。
+ + +

拖拽重排章节任务

+
这里会显示当前弧线下可拖拽排序的章节任务。
+ + + + + + +
+ + + + +
+ + + + +
+ + +
+ + + +
+ + +
+
+ +
+
+

承诺账本

+

承诺账本

+
+ + + + + + +
+ + + +
+
这里会显示当前诊断里的未收回承诺、最近回收承诺与逾期状态。
+
+ +
+
+

承诺映射

+

系列 / 分卷 / 弧线承诺映射

+
+
这里会显示当前选择的分卷 / 弧线对应到了哪些承诺,以及它们在诊断里出现在哪些章节。
+
+ +
+
+

修稿桥

+

章节任务与问题章节联动

+
+ + + + + + +
+ + +
+

计划与实际承诺偏移

+

偏移修复建议

+
这里会显示当前章节任务关联到了哪些问题章节、问题类型和承诺。
+
+ +
+
+

改写预览

+

对照建议改写预览

+
+
+ +
+
这里会显示当前任务表单值与改写建议之间的预览。
+
+ +
+
+

连续性对照

+

连续性差异

+
+ + + + + + + + +
+ + + +
+
这里会显示 continuity drift、causal break 与 before-after chapter compare。
+
+
-

Style / Pacing / Hook

+

诊断检查点

+

诊断差异检查点

+
+
+ +
+
这里会显示当前 rewrite revision 是否已经有对应 simulation diff checkpoint。
+
+ +
+
+

风格 / 节奏 / 钩子

风格 / 节奏 / Hook 控制面板

- +
这里会先显示当前风格词、节奏窗口和 Hook 状态。
+ @@ -705,29 +1196,259 @@

风格 / 节奏 / Hook 控制面板

- +
+
+
+

高级配置

+

对白 / 动作 / 感官

+
+ + + + + + + + +
+ +
+
+ +
+
+
+

Agent Studio

+

把故事目标定下来,再进入共同导演工作台

+

先确认作品方向、长度和读者体验,Studio 会创建本地作品并生成第一章。

+
+
+ + +
+ + + + +
+ + + + + +
+ + +
+
+
+ + +
+ + + +
+ + + + + + + + + + + + + + + + + + +
- + + +
+
这里会显示当前 campaign 表单状态和最近一次送审结果。
+
这里会显示当前账户的 campaigns 和 activation 状态。
+ +
+
+

客户工作台

+

账户 / 质量 / 结算 / 转化汇总

+
+
+ + + + + +
+
这里会显示当前账户的 campaign 摘要与可用容量。
+
登录 customer / reviewer / ops / admin 后,这里会显示客户账户生命周期摘要。
+
这里会显示当前套餐、价格和限额配置。
+
+
+
+

结算与限额

+

billing profile / limit posture

+
+
这里会显示 billing profile、invoice email 和税务状态。
+
这里会显示 seat / workspace / campaign 当前占用与阈值。
+
这里会显示导出状态和当前可用报告类型。
+
+
+
+

质量与依据性

+

质量 summary / groundedness summary

+
+
这里会显示渠道 / partner performance 和 allowlisted channel 摘要。
+
这里会显示通过 canonical 质量数据汇总的客户可见质量摘要。
+
这里会显示 groundedness pass rate、失败数和 unsupported claims 摘要。
+
+
+
+

回执与转化

+

receipt / handoff / conversion

+
这里会显示最近回执、provider 分布和 incident 摘要。
+
这里会显示 validated presented / handoff / conversion 的数量与金额。
+
这里会显示最近几条客户可见质量 trace 摘要。
+

这个工作台会继续承接 campaign 激活和更完整的客户报告,但当前版本已经可用于账户、质量、groundedness、回执和 invoice preview 的对外交付。

+
+
+
+

Dispute / Support

+

发起争议与支持工单

+
+ + + + + + + + +
+ +
+
这里会显示当前 dispute 摘要。
+
这里会显示当前账户的 disputes。
+ + + + + + +
+ +
+
这里会显示 support case 摘要。
+
这里会显示当前账户的 support cases。
@@ -736,11 +1457,11 @@

Voice / Action / Sensory

-

Ops Console

-

审核、发布、回滚与计费观察

+

运营台

+

分诊、审阅、发布与治理统一运营面

- 这里提供最小运营主路径:查看 review queue、发布世界版本、回滚版本,并观察 metering。 + 这里按统一审阅项组织运营工作流,让内容发布、治理、告警和支持问题落到同一套操作节奏里。

@@ -763,63 +1484,65 @@

审核、发布、回滚与计费观察

-

Ops Control Plane

+

运营控制台

统一导航 / 升级路径

- - - - - - - - + + + + + + + +
- - + +
-
这里会显示统一 context、升级状态与推荐路径。
-
这里会显示 linked targets 与导航入口。
-
这里会显示跨面板 follow-up actions。
+
这里会显示统一上下文、升级状态与推荐路径。
+
这里会显示关联对象与导航入口。
+
这里会显示跨面板后续动作。
-

Review Queue

-

待审核版本

+

统一审阅台

+

跨域审阅项

-
暂时没有待审核版本。
+
暂时没有统一审阅项。
-

World Status

-

世界版本状态

+

审阅详情

+

当前审阅项与跨域分诊

-
选择或刷新后,这里会显示 world version 状态。
+
选择一个统一审阅项后,这里会显示详情、动作与分诊摘要。
-

Content Release Workspace

-

发布 / Checklist / 回滚统一处置页

+

内容发布台

+ 发布 / Checklist / 回滚统一处置页 +

发布 / 清单 / 回滚统一处置页

- - + +
- + Refresh Release Workspace +
-
这里会显示当前 world 的 release summary。
-
这里会显示当前 world 的 quick actions。
-
这里会显示当前 world 的 operator timeline。
-
这里会显示 publish blockers、version matrix 与 rollback workspace。
+
这里会显示当前世界的发布摘要。
+
这里会显示当前世界的快捷动作。
+
这里会显示当前世界的运营时间线。
+
这里会显示发布阻塞项、版本矩阵与回滚工作台。
-

Metering

+

计量记录

计费与使用记录

继续阅读发生后,这里会出现 meter 记录。
@@ -827,617 +1550,631 @@

计费与使用记录

-

Monetization

+

会员与计费

会员 / 钱包 / 订阅审计

- - - + + + - + - + - + - - - - - - + + + + + +
- - - + + +
-
这里会显示当前 account 的 subscription 与 wallets。
-
这里会显示当前 account 的 monetization 事件时间线。
+
这里会显示当前账户的订阅和钱包情况。
+
这里会显示当前账户的计费事件时间线。
-

Account Workspace

+

账户工作台

账户详情 / 权益 / 订阅 / 钱包统一排查页

-
这里会显示当前 account 的 operator workspace summary。
-
这里会显示当前 account 的 quick actions 与推荐处置顺序。
-
这里会显示 account 级 operator timeline。
-
这里会显示当前 account 的订阅、钱包、gating 与最近 activity。
-
这里会显示当前 account 的最近 sessions / drafts / meters。
+
这里会显示当前账户的运营摘要。
+
这里会显示当前账户的快捷动作与推荐处置顺序。
+
这里会显示账户级运营时间线。
+
这里会显示当前账户的订阅、钱包、权限与最近活动。
+
这里会显示当前账户最近的会话、草稿和计量记录。
-

Support Tooling

+

客服工具

客服问题定位

-
这里会显示当前 account 的 support summary 与推荐动作。
-
这里会显示当前 account 的 issue lookup 结果。
+
这里会显示当前账户的客服摘要与推荐动作。
+
这里会显示当前账户的客服问题定位结果。
-

Alert Center

+

告警中心

主动告警与标准处置

- - + +
- + - + - - + +
- - - - -
-
这里会显示当前 alert feed 的统计摘要。
-
这里会显示主动告警 feed。
-
这里会显示选中 alert 的标准处置 bundle、runbook 和 investigation ref。
+ + + + +
+
这里会显示当前告警流的统计摘要。
+
这里会显示主动告警列表。
+
这里会显示选中告警的标准处置包、运行手册和排查引用。
-

Rights / Moderation / Abuse

-

治理 Case 流

+

权益 / 治理 / 滥用

+ 治理 Case 流 +

治理个案流

- - - + + + - + - - + +
- + - + - + - - -
- - - - - - - - - - - - - - - + + +
+ + + + + + + + + + + + + + + - - + +
- - - - - - + + + + + +
-
这里会显示当前 account 的 rights / moderation / abuse case 摘要。
-
这里会显示当前 account 关联的治理 cases。
-
这里会显示选中 governance case 的 drill-down。
+
这里会显示当前账户的权益、治理与滥用个案摘要。
+
这里会显示当前账户关联的治理个案列表。
+
这里会显示选中治理个案的深度详情。
这里会显示治理审计导出摘要。
-

Full Audit Trail

+

完整审计轨迹

完整审计轨迹

-
这里会显示当前 account 的审计分类、来源与最近动作摘要。
-
这里会显示当前 account 的完整 audit trail,包括 Reader / Author / Ops / meter 事件。
+
这里会显示当前账户的审计分类、来源与最近动作摘要。
+
这里会显示当前账户的完整审计轨迹,包括阅读、创作、运营与计量事件。
-

Unified Investigation

+

统一排查

统一排查路径

- - - - - - + + + + + +
- - + +
-
这里会显示 investigation summary 与推荐排查路径。
-
这里会显示统一 trace timeline。
-
这里会显示 evidence index。
+
这里会显示排查摘要与推荐排查路径。
+
这里会显示统一排查时间线。
+
这里会显示证据索引。
-

NarrativeEval

+

叙事评测

质量指标

-
这里会显示 pass / rewrite / block、top issues 与质量趋势。
+
这里会显示通过、重写、阻塞、核心问题与质量趋势。
-

Learned Dashboard

-

Learned Layer 总览

+

学习层总览

+

学习层总览

-
这里会显示 evaluator / reranker 的统一 learned summary。
+
这里会显示评估器和重排器的统一学习层摘要。
-

Learned Impact

-

继续读 / 付费代理指标相关性

+

学习层影响

+

继续阅读 / 付费代理指标相关性

-
这里会显示 evaluator / reranker 的 learned impact summary、retention proxy 与 monetization proxy。
+
这里会显示评估器和重排器的学习层影响摘要、留存代理与付费代理。
-

Learned Cadence

+

学习层节奏

训练 / 验证 / 晋升节奏

-
这里会显示 evaluator / reranker 当前处于 collect data、train、validate、promotion 还是 activate 阶段。
+
这里会显示评估器和重排器当前处于补数据、训练、验证、晋升还是全量启用阶段。
-

Assisted Gate Experiment

-

shadow -> assisted gate 受控实验

+

辅助门控实验

+

影子模式到辅助门控的受控实验

-
这里会显示 assisted gate experiment 的 config、guardrails、recent decisions 与 rollback 条件。
- +
这里会显示辅助门控实验的配置、护栏、最近决策与回滚条件。
+ - - - + + + - + - - + +
- - - + + +
-

Assisted Rerank Experiment

-

shadow -> assisted rerank 受控实验

+

辅助重排实验

+

影子模式到辅助重排的受控实验

-
这里会显示 assisted rerank experiment 的 config、guardrails、recent decisions 与 rollback 条件。
- +
这里会显示辅助重排实验的配置、护栏、最近决策与回滚条件。
+ - - - + + + - + - + - + - - + +
- - - + + +
-

Training Automation

-

训练自动化与 Evidence Pack

+

训练自动化

+

训练自动化与证据包

- - - + + +
-
这里会显示最近一次 learned training automation 结果。
-
这里会显示 evaluator / reranker 的 promotion evidence pack 摘要。
+
这里会显示最近一次学习层训练自动化结果。
+
这里会显示评估器和重排器的发布证据包摘要。
-

Shadow Candidate Compare

-

哪条 Learned 线更值得先推进

+

影子候选对比

+

哪条学习层候选线更值得先推进

-
这里会显示 evaluator / reranker 的 shadow candidate compare。
+
这里会显示评估器和重排器的影子候选对比。
-

Safe Rollout

-

Learned 组件灰度与回滚

+

安全灰度

+

学习层组件灰度与回滚

-
这里会显示 learned rollout summary、safe candidates 与 rollback watchlist。
+
这里会显示学习层灰度摘要、可安全发布候选与回滚观察名单。
-

Evaluator Promotion Gate

-

Evaluator 是否已可推荐进入下一阶段

+

评估器发布门

+

评估器是否已可推荐进入下一阶段

-
这里会显示 evaluator 的 promotion recommendation、blockers、advisories 与 checklist。
- +
这里会显示评估器的发布建议、阻塞项、提示项与检查清单。
+ - - + +
- - + +
-

Reranker Promotion Gate

-

Reranker 是否已可推荐进入下一阶段

+

重排器发布门

+

重排器是否已可推荐进入下一阶段

-
这里会显示 reranker 的 promotion recommendation、blockers、advisories 与 checklist。
- +
这里会显示重排器的发布建议、阻塞项、提示项与检查清单。
+ - - + +
- - + +
-

Weak Worlds

-

按 World 看薄弱点

+

薄弱世界

+

按世界看薄弱点

-
这里会显示需要优先看的 worlds。
+
这里会显示需要优先关注的薄弱世界。
-

Weak Issues

-

按 Issue 看薄弱点

+

薄弱问题

+

按问题看薄弱点

-
这里会显示需要优先看的 issue codes。
+
这里会显示需要优先关注的问题代码。
-

Detail

-

Learned Drill-down

+

详情钻取

+

学习层深度钻取

-
点击一个 world 或 issue 后,这里会显示 detail。
+
点击一个世界或问题后,这里会显示对应的深度详情。
-

Learned Data Ops

+

学习数据运营

补数据的下一步动作

-
这里会显示 review backlog、pair coverage backlog 和 action queue。
+
这里会显示审核待办、配对覆盖待办与行动队列。
-

Human Review Coverage

-

Human Review Coverage & Quality

+

人工审阅覆盖

+

人工审阅覆盖与质量

-
这里会显示 human review coverage、reviewer diversity、样本质量告警与高覆盖补样 backlog。
+
这里会显示人工审阅覆盖、审阅人分布、样本质量告警与高覆盖补样待办。
-

Review Backlog

+

审核待办

优先补的人审样本

这里会显示优先需要人工补样本的章节。
-

Pair Coverage Backlog

-

优先补的 Pair Coverage

+

配对覆盖待补样

+

优先补的配对覆盖

-
这里会显示需要更多 revision / review 才能长出 inferred pairs 的位置。
+
这里会显示需要更多版本对比与人工审阅才能形成有效偏好对的位置。
-

Quick Capture Review

-

快速补一条 Human Review

+

快速补录审阅

+

快速补一条人工审阅

-
点击 Review Backlog 里的章节后,这里会自动填充上下文。
- +
点击审核待办里的章节后,这里会自动填充上下文。
+ - + - - - + + +
- - + +
- +
-

Preference Capture

-

快速补一条 Pairwise Preference

+

偏好采集

+

快速补一条成对偏好

- - - - - - - + + + + + + + - +
- +
-
这里会显示最近采集的 preference samples。
+
这里会显示最近采集的偏好样本。
-

Ranking Capture

-

快速补一条 Revision Ranking

+

排序采集

+

快速补一条版本排序

- - - + + +
- +
-
这里会显示最近采集的 ranking samples。
+
这里会显示最近采集的排序样本。
-

Last Action Impact

+

最近动作影响

刚刚这次补数据带来了什么变化

-
提交一条 Human Review 后,这里会显示对 backlog / compare / next action 的即时影响。
+
提交一条人工审阅后,这里会显示对审核待办、章节对照和下一步动作的即时影响。
-

Cross-Pack Benchmark

-

Kernel Capability 趋势

+

跨包基准

+

内核能力趋势

+
+ + + + + +
+
-
这里会显示 cross-pack pass rate、top failing packs 与 metric delta。
+
这里会显示跨包通过率、薄弱世界与核心指标变化。
-

Review History

+

审核历史

审核与发布历史

-
这里会显示 world version 的审核、发布和回滚记录。
+
这里会显示世界版本的审核、发布和回滚记录。
-

Quality Trend

+

质量趋势

版本级质量趋势

-
这里会显示每个 world version 的 pass / rewrite / block 与 cross-pack 走势。
+
这里会显示每个世界版本的通过、重写、阻塞与跨包走势。
-

Schema Lifecycle

-

Postgres / Migration 状态

+

结构生命周期

+

数据库结构 / 迁移状态

-
这里会显示当前数据库 backend、migration pending 状态和 schema drift 摘要。
-

Data Integrity / Repair

- - +
这里会显示当前数据库后端、待执行迁移状态和结构漂移摘要。
+

数据一致性 / 修复

+ +
- - + +
-
这里会显示热点索引覆盖、session drift、orphan route choices 与 repair backlog。
+
这里会显示热点索引覆盖、会话漂移、孤儿分支选择与修复待办。
-

Runbook

-

Deployment / Backup / Incident

-
- - - - - - - +

运行手册

+

发布 / 备份 / 事故处置

+ + + + + + + + - + - - + +
- - - + + +
- - - - -
-
这里会显示 Deployment Health Gate 和总体放行状态。
-
这里会显示 preflight verification bundle 与推荐验证命令。
-
这里会显示 deployment runbook 与最近 backups。
-
这里会显示 incident playbook 与建议恢复步骤。
+ + + + + +
这里会显示发布健康门和总体放行状态。
+
这里会显示发布前校验包与推荐验证命令。
+
这里会显示发布运行手册与最近备份。
+
这里会显示事故处置手册与建议恢复步骤。
-

Async Jobs

-

Long-running workflow queue

-
- - - - - - +

异步任务

+

长任务工作流队列

+ + + + + + +
- - - - - - - - - - - -
-
这里会显示 long-running jobs 的队列摘要。
-
这里会显示 boot-time async reconciler 的处理结果。
-
这里会显示 failed / queued / stale running jobs 的 incident recovery 摘要。
-
这里会显示 async job artifact retention 与保留状态。
-
这里会显示 operator run history。
-
这里会显示 async job handoff bundle 与 acknowledgement 摘要。
-
这里会显示 async adapter config validation。
-
这里会显示 async adapter health probe。
-
这里会显示 notification delivery receipts。
-
这里会显示 notification retry queue。
-
这里会显示 Notification Dead-letter Queue。
-
这里会显示 Retry Outcome Dashboard。
-
这里会显示 learned training / runtime backup 的异步工作流状态。
+ + + + + + + + + + + + +
这里会显示长任务队列摘要。
+
这里会显示启动时异步对账器的处理结果。
+
这里会显示失败、排队中和长时间运行任务的事故恢复摘要。
+
这里会显示异步任务产物保留与保留状态。
+
这里会显示运营操作历史。
+
这里会显示异步任务交接包与确认摘要。
+
这里会显示异步适配器配置校验结果。
+
这里会显示异步适配器健康探测结果。
+
这里会显示通知送达回执。
+
这里会显示通知重试队列。
+
这里会显示通知死信队列。
+
这里会显示重试结果看板。
+
这里会显示学习层训练和运行时备份相关的异步工作流状态。
-

Observability

-

Runtime Receipts / Incident Snapshot

-
-
这里会显示 runtime incident snapshot、provider fallback、budget block 与 cache hit 概况。
-
这里会显示最近的 runtime receipts。
-
这里会显示 Provider Routing Policy,以及 candidate / renderer 当前的 routing policy。
-
这里会显示 candidate / renderer 的 canary / active / rollback 控制。
- +

运行观测

+

运行回执 / 事故快照

+ +
这里会显示运行时事故快照、通道回退、预算拦截与缓存命中概况。
+
这里会显示最近的运行回执。
+
这里会显示通道路由策略,以及候选链路和渲染链路的当前路由配置。
+
这里会显示候选链路和渲染链路的灰度、全量启用与回滚控制。
+ - - - + + + - - + +
- - - + + +
- - - + + +
-
这里会显示 Provider Runtime Metrics 与 cost trend dashboard。
+
这里会显示通道运行指标与成本趋势看板。
+
这里会显示 Story bootstrap 首轮质量门碰撞摘要。
+
这里会显示选中 world 的 Story bootstrap 重试明细。
@@ -1457,9 +2194,33 @@

- - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/narrativeos/web/ops_accessors.js b/src/narrativeos/web/ops_accessors.js new file mode 100644 index 0000000..2b3affb --- /dev/null +++ b/src/narrativeos/web/ops_accessors.js @@ -0,0 +1,16 @@ +// Ops state accessors shared across ops refresh/render helpers. + +var OpsAccessors = (() => { + function latestAsyncJob(jobType) { + return (opsState.opsAsyncJobs || []).find((item) => item.job_type === jobType) || null; + } + + function selectedReviewItem() { + return (opsState.opsReviewHub?.items || []).find((item) => item.review_item_id === opsState.opsSelectedReviewItemId) || null; + } + + return { + latestAsyncJob, + selectedReviewItem, + }; +})(); diff --git a/src/narrativeos/web/ops_actions.js b/src/narrativeos/web/ops_actions.js index c27f5d5..7c26e0f 100644 --- a/src/narrativeos/web/ops_actions.js +++ b/src/narrativeos/web/ops_actions.js @@ -1,11 +1,43 @@ // Ops action handlers extracted from app.js. +var OpsActionsRuntime = (() => { + const dom = OpsDOM; + const { api, setBusy, downloadJsonFile, parseTagList, reportUiMessage, parseErrorDetail } = UIShared; + const { applySupportPrefill, applyGovernanceCasePrefill } = OpsShared; + const { + syncOpsNavigationContext, + refreshOpsSurface, + refreshOpsAccountFlow, + refreshOpsReleaseFlow, + refreshOpsJobsFlow, + refreshOpsLearnedFlow, + refreshOpsCrossPackQuality, + refreshOpsReleaseWorkspace, + refreshOpsAlerts + } = OpsRefreshRuntime; + +function resolveActiveReaderId() { + if (typeof ReaderRuntime !== "undefined" && typeof ReaderRuntime.activeReaderId === "function") { + return ReaderRuntime.activeReaderId(); + } + return readerState.readerId || ""; +} + +function opsActiveReviewerId() { + return ( + dom.opsReviewerId?.value.trim() || + dom.opsGovernanceReviewerId?.value.trim() || + authorState.authorAuthSession?.identity?.actor_id || + "ops_web" + ); +} + function opsGovernanceHeaders() { - const reviewerId = els.opsGovernanceReviewerId?.value.trim() || "ops_web"; + const reviewerId = dom.opsGovernanceReviewerId?.value.trim() || "ops_web"; return { "X-NarrativeOS-Actor-Id": reviewerId, "X-NarrativeOS-Actor-Role": "reviewer", - ...(els.opsAccountId?.value.trim() ? { "X-NarrativeOS-Account-Id": els.opsAccountId.value.trim() } : {}), + ...(dom.opsAccountId?.value.trim() ? { "X-NarrativeOS-Account-Id": dom.opsAccountId.value.trim() } : {}), }; } @@ -16,18 +48,240 @@ function opsRestoreHeaders(actorId, actorRole) { }; } +function governanceStatusLabel(status) { + return { + in_review: "进入复核", + escalated: "升级处理", + resolved: "结案", + dismissed: "驳回结案", + open: "重新打开", + }[String(status || "")] || String(status || "更新"); +} + +function governanceOwnerDenialMessage(error, targetStatus) { + const raw = String(error?.message || ""); + const parsed = parseErrorDetail(error); + const detailText = + typeof parsed === "string" + ? parsed + : parsed && typeof parsed.detail === "string" + ? parsed.detail + : ""; + if (!raw.includes("governance_case_owner_required") && !detailText.includes("governance_case_owner_required")) { + return null; + } + const ownerId = + opsState.opsGovernanceDetail?.workflow_summary?.owner_id || + opsState.opsGovernanceDetail?.owner_id || + dom.opsGovernanceOwnerId?.value.trim() || + "当前 owner"; + return `当前 case 只能由 owner ${ownerId} 执行“${governanceStatusLabel(targetStatus)}”。请先改派 owner,或让 owner 继续处理。`; +} + +function showGovernanceOwnerDeniedBanner(targetStatus) { + const ownerId = + opsState.opsGovernanceDetail?.workflow_summary?.owner_id || + opsState.opsGovernanceDetail?.owner_id || + dom.opsGovernanceOwnerId?.value.trim() || + "当前 owner"; + const message = `当前 case 只能由 owner ${ownerId} 执行“${governanceStatusLabel(targetStatus)}”。请先改派 owner,或让 owner 继续处理。`; + reportUiMessage(message, "warning"); + return message; +} + +async function loadSelectedOpsReviewItem(reviewItemId) { + opsState.opsSelectedReviewItemId = reviewItemId; + opsState.opsSelectedReviewItemDetail = await api(`/v1/ops/review-items/${encodeURIComponent(reviewItemId)}`); + try { + opsState.opsSelectedReviewWorkDetail = await api(`/v1/ops/review-items/${encodeURIComponent(reviewItemId)}/work`); + } catch (_error) { + opsState.opsSelectedReviewWorkDetail = null; + } + const reviewItem = opsState.opsSelectedReviewItemDetail?.review_item || null; + const traceId = + reviewItem?.source_payload?.trace_summary?.trace_id || + (reviewItem?.linked_entities || []).find((entity) => entity.kind === "trace")?.id || + null; + if (traceId) { + await loadOpsQualityTraceDetail(traceId); + } else { + opsState.opsSelectedQualityTraceId = null; + opsState.opsQualityTraceDetail = null; + } +} + +async function loadOpsQualityTraceDetail(traceId) { + const normalizedTraceId = String(traceId || "").trim(); + if (!normalizedTraceId) { + opsState.opsSelectedQualityTraceId = null; + opsState.opsQualityTraceDetail = null; + return null; + } + opsState.opsSelectedQualityTraceId = normalizedTraceId; + opsState.opsQualityTraceDetail = await api(`/v1/ops/quality/traces/${encodeURIComponent(normalizedTraceId)}`); + return opsState.opsQualityTraceDetail; +} + +async function assignOpsReviewItem(reviewItemId, ownerId) { + await api(`/v1/ops/review-items/${encodeURIComponent(reviewItemId)}/assign`, { + method: "POST", + body: JSON.stringify({ + owner_id: ownerId, + reviewer_id: opsActiveReviewerId(), + }), + }); + await refreshOpsReleaseFlow(); +} + +async function updateOpsReviewItemStatus(reviewItemId, status) { + await api(`/v1/ops/review-items/${encodeURIComponent(reviewItemId)}/status`, { + method: "POST", + body: JSON.stringify({ + status, + reviewer_id: opsActiveReviewerId(), + }), + }); + await refreshOpsReleaseFlow(); +} + +async function decideOpsReviewItem(reviewItemId, decision) { + await api(`/v1/ops/review-items/${encodeURIComponent(reviewItemId)}/decision`, { + method: "POST", + body: JSON.stringify({ + decision, + reviewer_id: opsActiveReviewerId(), + }), + }); + await refreshOpsReleaseFlow(); +} + +async function runOpsReviewHubAction(reviewItem, actionId) { + const item = reviewItem || opsState.opsSelectedReviewItemDetail?.review_item || null; + if (!item) { + alert("请先选择一个统一审阅项。"); + return; + } + if (actionId === "assign_to_me") { + await assignOpsReviewItem(item.review_item_id, opsActiveReviewerId()); + return; + } + if (actionId === "mark_triaged") { + await updateOpsReviewItemStatus(item.review_item_id, "triaged"); + return; + } + if (actionId === "mark_in_review") { + await updateOpsReviewItemStatus(item.review_item_id, "in_review"); + return; + } + if (actionId === "resolve") { + await decideOpsReviewItem(item.review_item_id, "resolve"); + return; + } + if (actionId === "dismiss") { + await decideOpsReviewItem(item.review_item_id, "dismiss"); + return; + } + if (actionId === "approve") { + await decideOpsReviewItem(item.review_item_id, "approve"); + return; + } + if (actionId === "needs_changes") { + await decideOpsReviewItem(item.review_item_id, "needs_changes"); + return; + } + if (actionId === "block") { + await decideOpsReviewItem(item.review_item_id, "block"); + return; + } + if (actionId === "open_release_workspace") { + syncOpsNavigationContext( + { + world_id: item.world_id || undefined, + world_version_id: item.world_version_id || undefined, + account_id: item.account_id || undefined, + }, + { preserveExisting: false } + ); + shellState.activeProduct = "ops"; + shellState.opsWorkspace = "release"; + await refreshOpsReleaseFlow(); + ShellStatusRuntime.syncProductMode(); + return; + } + if (actionId === "open_account_workspace") { + syncOpsNavigationContext( + { + account_id: item.account_id || undefined, + world_id: item.world_id || undefined, + world_version_id: item.world_version_id || undefined, + }, + { preserveExisting: false } + ); + shellState.activeProduct = "ops"; + shellState.opsWorkspace = "account"; + await refreshOpsAccountFlow(); + ShellStatusRuntime.syncProductMode(); + return; + } + if (actionId === "open_governance_case") { + const caseLink = (item.linked_entities || []).find((entity) => entity.kind === "case"); + syncOpsNavigationContext( + { + account_id: item.account_id || undefined, + case_id: caseLink?.id || undefined, + world_id: item.world_id || undefined, + world_version_id: item.world_version_id || undefined, + }, + { preserveExisting: false } + ); + shellState.activeProduct = "ops"; + shellState.opsWorkspace = "alerts"; + await refreshOpsSurface({ scopes: ["alerts", "account", "navigation"], preserveLastActionImpact: true }); + ShellStatusRuntime.syncProductMode(); + return; + } + if (actionId === "open_investigation") { + syncOpsNavigationContext( + { + account_id: item.account_id || undefined, + world_id: item.world_id || undefined, + world_version_id: item.world_version_id || undefined, + }, + { preserveExisting: false } + ); + if (dom.opsInvestigationAccountId && item.account_id) dom.opsInvestigationAccountId.value = item.account_id; + if (dom.opsInvestigationWorldVersionId && item.world_version_id) dom.opsInvestigationWorldVersionId.value = item.world_version_id; + shellState.activeProduct = "ops"; + shellState.opsWorkspace = "account"; + await runOpsInvestigation({ silent: true }); + await refreshOpsAccountFlow({ preserveLastActionImpact: true }); + ShellStatusRuntime.syncProductMode(); + return; + } + if (actionId === "escalate_to_governance") { + applySupportPrefill({ + account_id: item.account_id || undefined, + world_version_id: item.world_version_id || undefined, + }); + shellState.activeProduct = "ops"; + shellState.opsWorkspace = "alerts"; + await refreshOpsSurface({ scopes: ["alerts", "account", "navigation"], preserveLastActionImpact: true }); + ShellStatusRuntime.syncProductMode(); + } +} + async function submitPromotionDecision(action) { - const reviewerId = els.opsPromotionReviewerId?.value.trim() || "ops_web"; - const reason = els.opsPromotionReason?.value.trim() || ""; + const reviewerId = dom.opsPromotionReviewerId?.value.trim() || "ops_web"; + const reason = dom.opsPromotionReason?.value.trim() || ""; if (!reviewerId || !reason) { alert("请填写 promotion reviewer_id 和 reason。"); return; } - const button = action === "approve" ? els.opsApprovePromotion : els.opsRevokePromotion; + const button = action === "approve" ? dom.opsApprovePromotion : dom.opsRevokePromotion; const restore = setBusy(button, action === "approve" ? "批准中…" : "撤销中…"); try { - appState.opsLearnedPromotion = await api( + opsState.opsLearnedPromotion = await api( action === "approve" ? "/v1/ops/learned-promotion/approve" : "/v1/ops/learned-promotion/revoke", { method: "POST", @@ -37,7 +291,7 @@ async function submitPromotionDecision(action) { }), } ); - appState.opsLastActionImpact = null; + opsState.opsLastActionImpact = null; await refreshOpsJobsFlow(); } catch (error) { alert(`更新 promotion 状态失败:${error.message}`); @@ -47,16 +301,16 @@ async function submitPromotionDecision(action) { } async function submitRerankerPromotionDecision(action) { - const reviewerId = els.opsRerankerPromotionReviewerId?.value.trim() || "ops_web"; - const reason = els.opsRerankerPromotionReason?.value.trim() || ""; + const reviewerId = dom.opsRerankerPromotionReviewerId?.value.trim() || "ops_web"; + const reason = dom.opsRerankerPromotionReason?.value.trim() || ""; if (!reviewerId || !reason) { alert("请填写 reranker promotion reviewer_id 和 reason。"); return; } - const button = action === "approve" ? els.opsApproveRerankerPromotion : els.opsRevokeRerankerPromotion; + const button = action === "approve" ? dom.opsApproveRerankerPromotion : dom.opsRevokeRerankerPromotion; const restore = setBusy(button, action === "approve" ? "批准中…" : "撤销中…"); try { - appState.opsLearnedRerankerPromotion = await api( + opsState.opsLearnedRerankerPromotion = await api( action === "approve" ? "/v1/ops/learned-reranker-promotion/approve" : "/v1/ops/learned-reranker-promotion/revoke", @@ -68,7 +322,7 @@ async function submitRerankerPromotionDecision(action) { }), } ); - appState.opsLastActionImpact = null; + opsState.opsLastActionImpact = null; await refreshOpsJobsFlow(); } catch (error) { alert(`更新 reranker promotion 状态失败:${error.message}`); @@ -78,26 +332,26 @@ async function submitRerankerPromotionDecision(action) { } async function submitProviderRollout(track, action) { - const reviewerId = els.opsProviderRolloutReviewerId?.value.trim() || "ops_web"; - const reason = els.opsProviderRolloutReason?.value.trim() || ""; + const reviewerId = dom.opsProviderRolloutReviewerId?.value.trim() || "ops_web"; + const reason = dom.opsProviderRolloutReason?.value.trim() || ""; if (!reviewerId || !reason) { alert("请填写 provider rollout reviewer_id 和 reason。"); return; } - const bucketPercentage = Number(els.opsProviderRolloutBucket?.value || 0); - const worldAllowlist = (els.opsProviderRolloutWorldAllowlist?.value || "") + const bucketPercentage = Number(dom.opsProviderRolloutBucket?.value || 0); + const worldAllowlist = (dom.opsProviderRolloutWorldAllowlist?.value || "") .split(",") .map((item) => item.trim()) .filter(Boolean); - let button = els.opsProviderCandidateCanary; - if (track === "candidate" && action === "activate") button = els.opsProviderCandidateActivate; - if (track === "candidate" && action === "rollback") button = els.opsProviderCandidateRollback; - if (track === "renderer" && action === "canary") button = els.opsProviderRendererCanary; - if (track === "renderer" && action === "activate") button = els.opsProviderRendererActivate; - if (track === "renderer" && action === "rollback") button = els.opsProviderRendererRollback; + let button = dom.opsProviderCandidateCanary; + if (track === "candidate" && action === "activate") button = dom.opsProviderCandidateActivate; + if (track === "candidate" && action === "rollback") button = dom.opsProviderCandidateRollback; + if (track === "renderer" && action === "canary") button = dom.opsProviderRendererCanary; + if (track === "renderer" && action === "activate") button = dom.opsProviderRendererActivate; + if (track === "renderer" && action === "rollback") button = dom.opsProviderRendererRollback; const restore = setBusy(button, action === "rollback" ? "回滚中…" : "保存中…"); try { - appState.opsProviderRollout = await api(`/v1/ops/provider-rollout/${encodeURIComponent(track)}/${encodeURIComponent(action)}`, { + opsState.opsProviderRollout = await api(`/v1/ops/provider-rollout/${encodeURIComponent(track)}/${encodeURIComponent(action)}`, { method: "POST", body: JSON.stringify({ reviewer_id: reviewerId, @@ -115,14 +369,14 @@ async function submitProviderRollout(track, action) { } async function runDataIntegrityRepair(apply) { - const rawActions = (els.opsDataIntegrityActions?.value || "") + const rawActions = (dom.opsDataIntegrityActions?.value || "") .split(",") .map((item) => item.trim()) .filter(Boolean); - const button = apply ? els.opsApplyDataIntegrityRepair : els.opsRunDataIntegrityDryRun; + const button = apply ? dom.opsApplyDataIntegrityRepair : dom.opsRunDataIntegrityDryRun; const restore = setBusy(button, apply ? "修复中…" : "扫描中…"); try { - appState.opsDataIntegrityRepair = await api("/v1/ops/data-integrity/repair", { + opsState.opsDataIntegrityRepair = await api("/v1/ops/data-integrity/repair", { method: "POST", body: JSON.stringify({ apply, @@ -139,30 +393,30 @@ async function runDataIntegrityRepair(apply) { } async function submitAssistedGateConfig(mode, enabled) { - const reviewerId = els.opsAssistedGateReviewerId?.value.trim() || "ops_web"; - const reason = els.opsAssistedGateReason?.value.trim() || ""; + const reviewerId = dom.opsAssistedGateReviewerId?.value.trim() || "ops_web"; + const reason = dom.opsAssistedGateReason?.value.trim() || ""; if (!reviewerId || !reason) { alert("请填写 assisted gate reviewer_id 和 reason。"); return; } const button = enabled - ? (mode === "assisted_gate" ? els.opsSetAssistedActive : els.opsSetAssistedShadow) - : els.opsDisableAssistedGate; + ? (mode === "assisted_gate" ? dom.opsSetAssistedActive : dom.opsSetAssistedShadow) + : dom.opsDisableAssistedGate; const restore = setBusy(button, enabled ? "保存中…" : "关闭中…"); try { - appState.opsLearnedAssistedGate = await api("/v1/ops/learned-assisted-gate/configure", { + opsState.opsLearnedAssistedGate = await api("/v1/ops/learned-assisted-gate/configure", { method: "POST", body: JSON.stringify({ reviewer_id: reviewerId, reason, enabled, mode, - bucket_percentage: Number(els.opsAssistedGateBucket?.value || 0), - confidence_threshold: Number(els.opsAssistedGateConfidence?.value || 0.9), + bucket_percentage: Number(dom.opsAssistedGateBucket?.value || 0), + confidence_threshold: Number(dom.opsAssistedGateConfidence?.value || 0.9), min_example_count: 3, min_high_confidence_blocks: 2, required_block_share: 0.5, - world_allowlist: (els.opsAssistedGateWorldAllowlist?.value || "") + world_allowlist: (dom.opsAssistedGateWorldAllowlist?.value || "") .split(",") .map((item) => item.trim()) .filter(Boolean), @@ -177,29 +431,29 @@ async function submitAssistedGateConfig(mode, enabled) { } async function submitAssistedRerankConfig(mode, enabled) { - const reviewerId = els.opsAssistedRerankReviewerId?.value.trim() || "ops_web"; - const reason = els.opsAssistedRerankReason?.value.trim() || ""; + const reviewerId = dom.opsAssistedRerankReviewerId?.value.trim() || "ops_web"; + const reason = dom.opsAssistedRerankReason?.value.trim() || ""; if (!reviewerId || !reason) { alert("请填写 assisted rerank reviewer_id 和 reason。"); return; } const button = enabled - ? (mode === "assisted_rerank" ? els.opsSetAssistedRerankActive : els.opsSetAssistedRerankShadow) - : els.opsDisableAssistedRerank; + ? (mode === "assisted_rerank" ? dom.opsSetAssistedRerankActive : dom.opsSetAssistedRerankShadow) + : dom.opsDisableAssistedRerank; const restore = setBusy(button, enabled ? "保存中…" : "关闭中…"); try { - appState.opsLearnedAssistedRerank = await api("/v1/ops/learned-assisted-rerank/configure", { + opsState.opsLearnedAssistedRerank = await api("/v1/ops/learned-assisted-rerank/configure", { method: "POST", body: JSON.stringify({ reviewer_id: reviewerId, reason, enabled, mode, - bucket_percentage: Number(els.opsAssistedRerankBucket?.value || 0), - confidence_threshold: Number(els.opsAssistedRerankConfidence?.value || 0.65), - candidate_window: Number(els.opsAssistedRerankCandidateWindow?.value || 3), - max_score_gap: Number(els.opsAssistedRerankMaxScoreGap?.value || 0.08), - world_allowlist: (els.opsAssistedRerankWorldAllowlist?.value || "") + bucket_percentage: Number(dom.opsAssistedRerankBucket?.value || 0), + confidence_threshold: Number(dom.opsAssistedRerankConfidence?.value || 0.65), + candidate_window: Number(dom.opsAssistedRerankCandidateWindow?.value || 3), + max_score_gap: Number(dom.opsAssistedRerankMaxScoreGap?.value || 0.08), + world_allowlist: (dom.opsAssistedRerankWorldAllowlist?.value || "") .split(",") .map((item) => item.trim()) .filter(Boolean), @@ -216,12 +470,12 @@ async function submitAssistedRerankConfig(mode, enabled) { async function submitLearnedRollout(track, action) { const reviewerId = track === "evaluator" - ? (els.opsPromotionReviewerId?.value.trim() || "ops_web") - : (els.opsRerankerPromotionReviewerId?.value.trim() || "ops_web"); + ? (dom.opsPromotionReviewerId?.value.trim() || "ops_web") + : (dom.opsRerankerPromotionReviewerId?.value.trim() || "ops_web"); const reason = track === "evaluator" - ? (els.opsPromotionReason?.value.trim() || "") - : (els.opsRerankerPromotionReason?.value.trim() || ""); + ? (dom.opsPromotionReason?.value.trim() || "") + : (dom.opsRerankerPromotionReason?.value.trim() || ""); if (!reviewerId || !reason) { alert("请先填写对应 track 的 reviewer_id 和 reason。"); return; @@ -231,7 +485,7 @@ async function submitLearnedRollout(track, action) { ? `/v1/ops/learned-rollout/${encodeURIComponent(track)}/activate` : `/v1/ops/learned-rollout/${encodeURIComponent(track)}/rollback`; try { - appState.opsLearnedRollout = await api(endpoint, { + opsState.opsLearnedRollout = await api(endpoint, { method: "POST", body: JSON.stringify({ reviewer_id: reviewerId, @@ -245,48 +499,49 @@ async function submitLearnedRollout(track, action) { } async function createGovernanceCase() { - const accountId = els.opsAccountId?.value.trim() || activeReaderId(); - const targetType = els.opsGovernanceTargetType?.value || "account"; - const targetId = (els.opsGovernanceTargetId?.value || "").trim() || (targetType === "account" ? accountId : ""); - const summary = (els.opsGovernanceSummaryInput?.value || "").trim(); - const reviewerId = (els.opsGovernanceReviewerId?.value || "ops_web").trim(); + const accountId = dom.opsAccountId?.value.trim() || resolveActiveReaderId(); + const targetType = dom.opsGovernanceTargetType?.value || "account"; + const targetId = (dom.opsGovernanceTargetId?.value || "").trim() || (targetType === "account" ? accountId : ""); + const summary = (dom.opsGovernanceSummaryInput?.value || "").trim(); + const reviewerId = (dom.opsGovernanceReviewerId?.value || "ops_web").trim(); if (!targetId || !summary) { alert("请填写 governance case 的 target_id 和 summary。"); return; } - const restore = setBusy(els.opsCreateGovernanceCase, "创建中…"); + const restore = setBusy(dom.opsCreateGovernanceCase, "创建中…"); try { const payload = await api("/v1/ops/governance/cases", { method: "POST", headers: opsGovernanceHeaders(), body: JSON.stringify({ - case_type: els.opsGovernanceCaseType?.value || "rights", + case_type: dom.opsGovernanceCaseType?.value || "rights", target_type: targetType, target_id: targetId, account_id: accountId || undefined, world_version_id: targetType === "world_version" ? targetId : undefined, session_id: targetType === "session" ? targetId : undefined, entitlement_id: targetType === "entitlement" ? targetId : undefined, - severity: els.opsGovernanceSeverity?.value || "medium", + severity: dom.opsGovernanceSeverity?.value || "medium", summary, - description: (els.opsGovernanceNotes?.value || "").trim() || undefined, + description: (dom.opsGovernanceNotes?.value || "").trim() || undefined, reviewer_id: reviewerId, - owner_id: (els.opsGovernanceOwnerId?.value || "").trim() || reviewerId, - due_at: (els.opsGovernanceDueAt?.value || "").trim() || undefined, - disposition: (els.opsGovernanceDisposition?.value || "").trim() || undefined, - policy_labels: parseTagList(els.opsGovernancePolicyLabels?.value || ""), - evidence_refs: (els.opsGovernanceEvidencePreview?.value || "").trim() + owner_id: (dom.opsGovernanceOwnerId?.value || "").trim() || reviewerId, + due_at: (dom.opsGovernanceDueAt?.value || "").trim() || undefined, + disposition: (dom.opsGovernanceDisposition?.value || "").trim() || undefined, + policy_labels: parseTagList(dom.opsGovernancePolicyLabels?.value || ""), + evidence_refs: (dom.opsGovernanceEvidencePreview?.value || "").trim() ? [ { - title: (els.opsGovernanceEvidenceTitle?.value || "").trim() || "manual_note", - preview: (els.opsGovernanceEvidencePreview?.value || "").trim(), + title: (dom.opsGovernanceEvidenceTitle?.value || "").trim() || "manual_note", + preview: (dom.opsGovernanceEvidencePreview?.value || "").trim(), kind: "note", }, ] : [], }), }); - if (els.opsGovernanceCaseId) els.opsGovernanceCaseId.value = payload.case?.case_id || ""; + if (dom.opsGovernanceCaseId) dom.opsGovernanceCaseId.value = payload.case?.case_id || ""; + await refreshOpsAccountFlow(); await refreshOpsJobsFlow(); if (payload.case?.case_id) { await openGovernanceCaseDetail(payload.case.case_id); @@ -299,60 +554,67 @@ async function createGovernanceCase() { } async function updateGovernanceCaseStatus() { - const caseId = (els.opsGovernanceCaseId?.value || "").trim(); + const caseId = (dom.opsGovernanceCaseId?.value || "").trim(); + const targetStatus = dom.opsGovernanceStatus?.value || "in_review"; if (!caseId) { alert("先选择或填写一个 governance case id。"); return; } - const restore = setBusy(els.opsUpdateGovernanceCase, "更新中…"); + const restore = setBusy(dom.opsUpdateGovernanceCase, "更新中…"); try { await api(`/v1/ops/governance/cases/${encodeURIComponent(caseId)}/status`, { method: "POST", headers: opsGovernanceHeaders(), body: JSON.stringify({ - status: els.opsGovernanceStatus?.value || "in_review", - reviewer_id: (els.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", - resolution_notes: (els.opsGovernanceNotes?.value || "").trim() || undefined, - disposition: (els.opsGovernanceDisposition?.value || "").trim() || undefined, + status: targetStatus, + reviewer_id: (dom.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", + resolution_notes: (dom.opsGovernanceNotes?.value || "").trim() || undefined, + disposition: (dom.opsGovernanceDisposition?.value || "").trim() || undefined, }), }); + await refreshOpsAccountFlow(); await refreshOpsJobsFlow(); await openGovernanceCaseDetail(caseId); } catch (error) { - alert(`更新 governance case 状态失败:${error.message}`); + const ownerDeniedMessage = governanceOwnerDenialMessage(error, targetStatus); + if (ownerDeniedMessage) { + showGovernanceOwnerDeniedBanner(targetStatus); + } else { + alert(`更新 governance case 状态失败:${error.message}`); + } } finally { restore(); } } async function applyGovernanceRestriction() { - const accountId = els.opsAccountId?.value.trim() || activeReaderId(); - const reviewerId = (els.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web"; - const summary = (els.opsGovernanceSummaryInput?.value || "").trim(); + const accountId = dom.opsAccountId?.value.trim() || resolveActiveReaderId(); + const reviewerId = (dom.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web"; + const summary = (dom.opsGovernanceSummaryInput?.value || "").trim(); if (!accountId || !summary) { alert("请填写 account_id 和 restriction summary。"); return; } - const restore = setBusy(els.opsApplyGovernanceRestriction, "施加中…"); + const restore = setBusy(dom.opsApplyGovernanceRestriction, "施加中…"); try { const payload = await api("/v1/ops/governance/restrictions", { method: "POST", headers: opsGovernanceHeaders(), body: JSON.stringify({ - restriction_type: els.opsGovernanceRestrictionType?.value || "account_hold", + restriction_type: dom.opsGovernanceRestrictionType?.value || "account_hold", account_id: accountId, - case_type: els.opsGovernanceCaseType?.value || "abuse", - severity: els.opsGovernanceSeverity?.value || "high", + case_type: dom.opsGovernanceCaseType?.value || "abuse", + severity: dom.opsGovernanceSeverity?.value || "high", summary, - description: (els.opsGovernanceNotes?.value || "").trim() || undefined, + description: (dom.opsGovernanceNotes?.value || "").trim() || undefined, reviewer_id: reviewerId, - expires_at: (els.opsGovernanceRestrictionExpiresAt?.value || "").trim() || undefined, - restriction_reason: (els.opsGovernanceNotes?.value || "").trim() || summary, + expires_at: (dom.opsGovernanceRestrictionExpiresAt?.value || "").trim() || undefined, + restriction_reason: (dom.opsGovernanceNotes?.value || "").trim() || summary, }), }); - await refreshOpsLearnedFlow(); + await refreshOpsAccountFlow(); if (payload.case?.case_id) { - if (els.opsGovernanceCaseId) els.opsGovernanceCaseId.value = payload.case.case_id; + if (dom.opsGovernanceCaseId) dom.opsGovernanceCaseId.value = payload.case.case_id; await openGovernanceCaseDetail(payload.case.case_id); } } catch (error) { @@ -363,22 +625,22 @@ async function applyGovernanceRestriction() { } async function releaseGovernanceRestriction() { - const restrictionId = (els.opsGovernanceCaseId?.value || "").trim(); + const restrictionId = (dom.opsGovernanceCaseId?.value || "").trim(); if (!restrictionId) { alert("先在 Case ID 中填入 restriction_id。"); return; } - const restore = setBusy(els.opsReleaseGovernanceRestriction, "释放中…"); + const restore = setBusy(dom.opsReleaseGovernanceRestriction, "释放中…"); try { const payload = await api(`/v1/ops/governance/restrictions/${encodeURIComponent(restrictionId)}/release`, { method: "POST", headers: opsGovernanceHeaders(), body: JSON.stringify({ - reviewer_id: (els.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", - release_reason: (els.opsGovernanceNotes?.value || "").trim() || undefined, + reviewer_id: (dom.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", + release_reason: (dom.opsGovernanceNotes?.value || "").trim() || undefined, }), }); - await refreshOpsJobsFlow(); + await refreshOpsAccountFlow(); if (payload.case?.case_id) { await openGovernanceCaseDetail(payload.case.case_id); } @@ -390,25 +652,25 @@ async function releaseGovernanceRestriction() { } async function assignGovernanceCase() { - const caseId = (els.opsGovernanceCaseId?.value || "").trim(); - const ownerId = (els.opsGovernanceOwnerId?.value || "").trim(); + const caseId = (dom.opsGovernanceCaseId?.value || "").trim(); + const ownerId = (dom.opsGovernanceOwnerId?.value || "").trim(); if (!caseId || !ownerId) { alert("先填写 case id 和 owner id。"); return; } - const restore = setBusy(els.opsAssignGovernanceCase, "分配中…"); + const restore = setBusy(dom.opsAssignGovernanceCase, "分配中…"); try { await api(`/v1/ops/governance/cases/${encodeURIComponent(caseId)}/assign`, { method: "POST", headers: opsGovernanceHeaders(), body: JSON.stringify({ owner_id: ownerId, - reviewer_id: (els.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", - due_at: (els.opsGovernanceDueAt?.value || "").trim() || undefined, - note: (els.opsGovernanceNotes?.value || "").trim() || undefined, + reviewer_id: (dom.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", + due_at: (dom.opsGovernanceDueAt?.value || "").trim() || undefined, + note: (dom.opsGovernanceNotes?.value || "").trim() || undefined, }), }); - await refreshOpsJobsFlow(); + await refreshOpsAccountFlow(); await openGovernanceCaseDetail(caseId); } catch (error) { alert(`分配 governance case 失败:${error.message}`); @@ -418,25 +680,25 @@ async function assignGovernanceCase() { } async function addGovernanceEvidence() { - const caseId = (els.opsGovernanceCaseId?.value || "").trim(); - const preview = (els.opsGovernanceEvidencePreview?.value || "").trim(); + const caseId = (dom.opsGovernanceCaseId?.value || "").trim(); + const preview = (dom.opsGovernanceEvidencePreview?.value || "").trim(); if (!caseId || !preview) { alert("先填写 case id 和 evidence preview。"); return; } - const restore = setBusy(els.opsAddGovernanceEvidence, "记录中…"); + const restore = setBusy(dom.opsAddGovernanceEvidence, "记录中…"); try { await api(`/v1/ops/governance/cases/${encodeURIComponent(caseId)}/evidence`, { method: "POST", headers: opsGovernanceHeaders(), body: JSON.stringify({ - reviewer_id: (els.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", - title: (els.opsGovernanceEvidenceTitle?.value || "").trim() || "manual_note", + reviewer_id: (dom.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", + title: (dom.opsGovernanceEvidenceTitle?.value || "").trim() || "manual_note", preview, kind: "note", }), }); - await refreshOpsLearnedFlow(); + await refreshOpsAccountFlow(); await openGovernanceCaseDetail(caseId); } catch (error) { alert(`添加 governance evidence 失败:${error.message}`); @@ -446,33 +708,33 @@ async function addGovernanceEvidence() { } async function refreshGovernanceAuditExport() { - const accountId = els.opsAccountId?.value.trim() || activeReaderId(); + const accountId = dom.opsAccountId?.value.trim() || resolveActiveReaderId(); if (!accountId) return; try { - appState.opsGovernanceExport = await api(`/v1/ops/export/governance-audit?account_id=${encodeURIComponent(accountId)}`); - renderOpsSurface(); + opsState.opsGovernanceExport = await api(`/v1/ops/export/governance-audit?account_id=${encodeURIComponent(accountId)}`); + OpsRenderRuntime.renderOpsSurface(); } catch (error) { alert(`刷新治理导出失败:${error.message}`); } } async function createRuntimeBackup() { - const restore = setBusy(els.opsCreateRuntimeBackup, "备份中…"); + const restore = setBusy(dom.opsCreateRuntimeBackup, "备份中…"); try { const payload = await api("/v1/ops/jobs/runtime-backups", { method: "POST", body: JSON.stringify({ - label: (els.opsBackupLabel?.value || "").trim() || undefined, - requested_by: (els.opsRestoreRequesterId?.value || "ops_web").trim() || "ops_web", - account_id: els.opsAccountId?.value.trim() || activeReaderId(), + label: (dom.opsBackupLabel?.value || "").trim() || undefined, + requested_by: (dom.opsRestoreRequesterId?.value || "ops_web").trim() || "ops_web", + account_id: dom.opsAccountId?.value.trim() || resolveActiveReaderId(), }), }); await refreshOpsJobsFlow(); const latestBackupJob = payload.job?.job_id - ? appState.opsAsyncJobs.find((item) => item.job_id === payload.job.job_id) + ? opsState.opsAsyncJobs.find((item) => item.job_id === payload.job.job_id) : latestAsyncJob("runtime_backup"); - if (els.opsRestorePath && latestBackupJob?.result_summary?.backup_path) { - els.opsRestorePath.value = latestBackupJob.result_summary.backup_path; + if (dom.opsRestorePath && latestBackupJob?.result_summary?.backup_path) { + dom.opsRestorePath.value = latestBackupJob.result_summary.backup_path; } } catch (error) { alert(`创建 runtime backup 失败:${error.message}`); @@ -482,12 +744,12 @@ async function createRuntimeBackup() { } async function restoreRuntimeBackup() { - const backupPath = (els.opsRestorePath?.value || "").trim(); + const backupPath = (dom.opsRestorePath?.value || "").trim(); if (!backupPath) { alert("请先填写 backup path。"); return; } - const restore = setBusy(els.opsRestoreRuntimeBackup, "恢复中…"); + const restore = setBusy(dom.opsRestoreRuntimeBackup, "恢复中…"); try { await api("/v1/ops/runtime-restore", { method: "POST", @@ -504,8 +766,8 @@ async function restoreRuntimeBackup() { } async function runRecoveryDrill() { - const backupPath = (els.opsRestorePath?.value || "").trim() || undefined; - const restore = setBusy(els.opsRunRecoveryDrill, "演练中…"); + const backupPath = (dom.opsRestorePath?.value || "").trim() || undefined; + const restore = setBusy(dom.opsRunRecoveryDrill, "演练中…"); try { const payload = await api("/v1/ops/recovery-drill", { method: "POST", @@ -513,7 +775,7 @@ async function runRecoveryDrill() { backup_path: backupPath, }), }); - appState.opsRecoveryDrillResult = payload.recovery_drill || null; + opsState.opsRecoveryDrillResult = payload.recovery_drill || null; await refreshOpsSurface({ scopes: ["runtime"] }); } catch (error) { alert(`执行 recovery drill 失败:${error.message}`); @@ -523,14 +785,14 @@ async function runRecoveryDrill() { } async function requestRuntimeRestore() { - const backupPath = (els.opsRestorePath?.value || "").trim(); - const requestedBy = (els.opsRestoreRequesterId?.value || "").trim() || "ops_web"; - const reason = (els.opsRestoreReason?.value || "").trim(); + const backupPath = (dom.opsRestorePath?.value || "").trim(); + const requestedBy = (dom.opsRestoreRequesterId?.value || "").trim() || "ops_web"; + const reason = (dom.opsRestoreReason?.value || "").trim(); if (!backupPath || !reason) { alert("请填写 restore backup path 和 restore reason。"); return; } - const restore = setBusy(els.opsRequestRuntimeRestore, "请求中…"); + const restore = setBusy(dom.opsRequestRuntimeRestore, "请求中…"); try { const payload = await api("/v1/ops/runtime-restore/request", { method: "POST", @@ -540,8 +802,8 @@ async function requestRuntimeRestore() { reason, }), }); - if (els.opsRestoreRequestId && payload.restore_request?.request_id) { - els.opsRestoreRequestId.value = payload.restore_request.request_id; + if (dom.opsRestoreRequestId && payload.restore_request?.request_id) { + dom.opsRestoreRequestId.value = payload.restore_request.request_id; } await refreshOpsSurface({ scopes: ["runtime"] }); } catch (error) { @@ -552,14 +814,14 @@ async function requestRuntimeRestore() { } async function approveRuntimeRestore() { - const requestId = (els.opsRestoreRequestId?.value || "").trim(); - const approverId = (els.opsRestoreApproverId?.value || "").trim() || "ops_approver"; - const reason = (els.opsRestoreReason?.value || "").trim(); + const requestId = (dom.opsRestoreRequestId?.value || "").trim(); + const approverId = (dom.opsRestoreApproverId?.value || "").trim() || "ops_approver"; + const reason = (dom.opsRestoreReason?.value || "").trim(); if (!requestId || !reason) { alert("请填写 restore request id 和 restore reason。"); return; } - const restore = setBusy(els.opsApproveRuntimeRestore, "批准中…"); + const restore = setBusy(dom.opsApproveRuntimeRestore, "批准中…"); try { await api(`/v1/ops/runtime-restore/${encodeURIComponent(requestId)}/approve`, { method: "POST", @@ -577,14 +839,14 @@ async function approveRuntimeRestore() { } async function revokeRuntimeRestore() { - const requestId = (els.opsRestoreRequestId?.value || "").trim(); - const reviewerId = (els.opsRestoreApproverId?.value || "").trim() || "ops_approver"; - const reason = (els.opsRestoreReason?.value || "").trim(); + const requestId = (dom.opsRestoreRequestId?.value || "").trim(); + const reviewerId = (dom.opsRestoreApproverId?.value || "").trim() || "ops_approver"; + const reason = (dom.opsRestoreReason?.value || "").trim(); if (!requestId || !reason) { alert("请填写 restore request id 和 restore reason。"); return; } - const restore = setBusy(els.opsRevokeRuntimeRestore, "撤销中…"); + const restore = setBusy(dom.opsRevokeRuntimeRestore, "撤销中…"); try { await api(`/v1/ops/runtime-restore/${encodeURIComponent(requestId)}/revoke`, { method: "POST", @@ -602,13 +864,13 @@ async function revokeRuntimeRestore() { } async function executeRuntimeRestore() { - const requestId = (els.opsRestoreRequestId?.value || "").trim(); - const executorId = (els.opsRestoreApproverId?.value || "").trim() || "ops_approver"; + const requestId = (dom.opsRestoreRequestId?.value || "").trim(); + const executorId = (dom.opsRestoreApproverId?.value || "").trim() || "ops_approver"; if (!requestId) { alert("请填写 restore request id。"); return; } - const restore = setBusy(els.opsExecuteRuntimeRestore, "执行中…"); + const restore = setBusy(dom.opsExecuteRuntimeRestore, "执行中…"); try { await api("/v1/ops/jobs/runtime-restores", { method: "POST", @@ -626,17 +888,17 @@ async function executeRuntimeRestore() { } async function retryAsyncJob() { - const jobId = (els.opsAsyncJobId?.value || "").trim(); + const jobId = (dom.opsAsyncJobId?.value || "").trim(); if (!jobId) { alert("请先填写 async job id。"); return; } - const restore = setBusy(els.opsRetryAsyncJob, "重试中…"); + const restore = setBusy(dom.opsRetryAsyncJob, "重试中…"); try { await api(`/v1/ops/jobs/${encodeURIComponent(jobId)}/retry`, { method: "POST", body: JSON.stringify({ - requested_by: (els.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", + requested_by: (dom.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", }), }); await refreshOpsJobsFlow(); @@ -648,17 +910,17 @@ async function retryAsyncJob() { } async function resumeAsyncJob() { - const jobId = (els.opsAsyncJobId?.value || "").trim(); + const jobId = (dom.opsAsyncJobId?.value || "").trim(); if (!jobId) { alert("请先填写 async job id。"); return; } - const restore = setBusy(els.opsResumeAsyncJob, "恢复中…"); + const restore = setBusy(dom.opsResumeAsyncJob, "恢复中…"); try { await api(`/v1/ops/jobs/${encodeURIComponent(jobId)}/resume`, { method: "POST", body: JSON.stringify({ - requested_by: (els.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", + requested_by: (dom.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", stale_after_minutes: 15, }), }); @@ -671,12 +933,12 @@ async function resumeAsyncJob() { } async function recoverAsyncJobIncidents() { - const restore = setBusy(els.opsRecoverAsyncJobs, "恢复中…"); + const restore = setBusy(dom.opsRecoverAsyncJobs, "恢复中…"); try { await api("/v1/ops/jobs/recover-incidents", { method: "POST", body: JSON.stringify({ - requested_by: (els.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", + requested_by: (dom.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", stale_after_minutes: 15, limit: 10, }), @@ -690,12 +952,12 @@ async function recoverAsyncJobIncidents() { } async function enforceAsyncJobRetention() { - const restore = setBusy(els.opsEnforceAsyncRetention, "清理中…"); + const restore = setBusy(dom.opsEnforceAsyncRetention, "清理中…"); try { const payload = await api("/v1/ops/jobs/enforce-retention", { method: "POST", body: JSON.stringify({ - requested_by: (els.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", + requested_by: (dom.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", dry_run: false, limit: 20, }), @@ -710,12 +972,12 @@ async function enforceAsyncJobRetention() { } async function runColdStartRecoveryDrill() { - const restore = setBusy(els.opsRunColdStartDrill, "演练中…"); + const restore = setBusy(dom.opsRunColdStartDrill, "演练中…"); try { const payload = await api("/v1/ops/jobs/cold-start-drill", { method: "POST", body: JSON.stringify({ - requested_by: (els.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", + requested_by: (dom.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", stale_after_minutes: 15, limit: 20, }), @@ -730,17 +992,17 @@ async function runColdStartRecoveryDrill() { } async function exportAsyncJobHandoffBundle() { - const restore = setBusy(els.opsExportHandoffBundle, "导出中…"); + const restore = setBusy(dom.opsExportHandoffBundle, "导出中…"); try { const payload = await api("/v1/ops/jobs/handoff-bundle/export", { method: "POST", body: JSON.stringify({ - requested_by: (els.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", + requested_by: (dom.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", limit: 20, }), }); - appState.opsAsyncJobHandoffBundle = payload; - renderOpsSurface(); + opsState.opsAsyncJobHandoffBundle = payload; + OpsRenderRuntime.renderOpsSurface(); alert(`Handoff bundle 已导出:${payload.export_path || "-"}`); } catch (error) { alert(`导出 handoff bundle 失败:${error.message}`); @@ -750,18 +1012,18 @@ async function exportAsyncJobHandoffBundle() { } async function acknowledgeAsyncJob() { - const jobId = (els.opsAsyncJobId?.value || "").trim(); + const jobId = (dom.opsAsyncJobId?.value || "").trim(); if (!jobId) { alert("请先填写 async job id。"); return; } - const restore = setBusy(els.opsAcknowledgeAsyncJob, "确认中…"); + const restore = setBusy(dom.opsAcknowledgeAsyncJob, "确认中…"); try { await api(`/v1/ops/jobs/${encodeURIComponent(jobId)}/acknowledge`, { method: "POST", body: JSON.stringify({ - requested_by: (els.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", - note: (els.opsAsyncJobNote?.value || "").trim() || undefined, + requested_by: (dom.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", + note: (dom.opsAsyncJobNote?.value || "").trim() || undefined, }), }); await refreshOpsJobsFlow(); @@ -773,17 +1035,17 @@ async function acknowledgeAsyncJob() { } async function shipRemoteArtifacts() { - const jobId = (els.opsAsyncJobId?.value || "").trim(); + const jobId = (dom.opsAsyncJobId?.value || "").trim(); if (!jobId) { alert("请先填写 async job id。"); return; } - const restore = setBusy(els.opsShipRemoteArtifacts, "运输中…"); + const restore = setBusy(dom.opsShipRemoteArtifacts, "运输中…"); try { const payload = await api(`/v1/ops/jobs/${encodeURIComponent(jobId)}/ship-remote`, { method: "POST", body: JSON.stringify({ - requested_by: (els.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", + requested_by: (dom.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", dry_run: false, }), }); @@ -797,12 +1059,12 @@ async function shipRemoteArtifacts() { } async function escalateHandoffSla() { - const restore = setBusy(els.opsEscalateHandoffSla, "升级中…"); + const restore = setBusy(dom.opsEscalateHandoffSla, "升级中…"); try { const payload = await api("/v1/ops/jobs/handoff-sla/escalate", { method: "POST", body: JSON.stringify({ - requested_by: (els.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", + requested_by: (dom.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", sla_minutes: 240, limit: 20, dry_run: false, @@ -818,23 +1080,23 @@ async function escalateHandoffSla() { } async function enqueueNotificationRetry() { - const receiptId = (els.opsNotificationReceiptId?.value || "").trim(); + const receiptId = (dom.opsNotificationReceiptId?.value || "").trim(); if (!receiptId) { alert("请先填写 notification receipt id。"); return; } - const restore = setBusy(els.opsEnqueueNotificationRetry, "入队中…"); + const restore = setBusy(dom.opsEnqueueNotificationRetry, "入队中…"); try { const payload = await api("/v1/ops/jobs/notification-retry-queue/enqueue", { method: "POST", body: JSON.stringify({ event_id: Number(receiptId), - requested_by: (els.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", - note: (els.opsAsyncJobNote?.value || "").trim() || undefined, + requested_by: (dom.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", + note: (dom.opsAsyncJobNote?.value || "").trim() || undefined, }), }); - if (els.opsNotificationReceiptId) { - els.opsNotificationReceiptId.value = payload.retry?.retry_id || receiptId; + if (dom.opsNotificationReceiptId) { + dom.opsNotificationReceiptId.value = payload.retry?.retry_id || receiptId; } await refreshOpsAccountFlow(); } catch (error) { @@ -845,17 +1107,17 @@ async function enqueueNotificationRetry() { } async function processNotificationRetry() { - const retryId = (els.opsNotificationReceiptId?.value || "").trim(); + const retryId = (dom.opsNotificationReceiptId?.value || "").trim(); if (!retryId) { alert("请先填写 notification retry id。"); return; } - const restore = setBusy(els.opsProcessNotificationRetry, "处理中…"); + const restore = setBusy(dom.opsProcessNotificationRetry, "处理中…"); try { await api(`/v1/ops/jobs/notification-retry-queue/${encodeURIComponent(retryId)}/process`, { method: "POST", body: JSON.stringify({ - requested_by: (els.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", + requested_by: (dom.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", dry_run: false, }), }); @@ -870,17 +1132,17 @@ async function processNotificationRetry() { async function runLearnedTraining(tracks) { const button = tracks.length === 2 - ? els.opsRunBothTraining + ? dom.opsRunBothTraining : tracks[0] === "evaluator" - ? els.opsRunEvaluatorTraining - : els.opsRunRerankerTraining; + ? dom.opsRunEvaluatorTraining + : dom.opsRunRerankerTraining; const restore = setBusy(button, "运行中…"); try { - appState.opsLearnedTrainingResult = await api("/v1/ops/jobs/learned-training", { + opsState.opsLearnedTrainingResult = await api("/v1/ops/jobs/learned-training", { method: "POST", body: JSON.stringify({ tracks, - requested_by: (els.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", + requested_by: (dom.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", }), }); await refreshOpsAccountFlow(); @@ -893,45 +1155,45 @@ async function runLearnedTraining(tracks) { async function openGovernanceCaseDetail(caseId) { if (!caseId) return; - appState.opsGovernanceDetail = await api(`/v1/ops/governance/cases/${encodeURIComponent(caseId)}`, { + opsState.opsGovernanceDetail = await api(`/v1/ops/governance/cases/${encodeURIComponent(caseId)}`, { headers: opsGovernanceHeaders(), }); applyGovernanceCasePrefill({ - case_id: appState.opsGovernanceDetail.case_id, - case_type: appState.opsGovernanceDetail.case_type, - target_type: appState.opsGovernanceDetail.target_type, - target_id: appState.opsGovernanceDetail.target_id, - severity: appState.opsGovernanceDetail.severity, - reviewer_id: appState.opsGovernanceDetail.reviewer_id, - owner_id: appState.opsGovernanceDetail.workflow_summary?.owner_id || appState.opsGovernanceDetail.owner_id, - summary: appState.opsGovernanceDetail.summary, - description: appState.opsGovernanceDetail.resolution_notes || appState.opsGovernanceDetail.description, - status: appState.opsGovernanceDetail.status, - account_id: appState.opsGovernanceDetail.account_id, - due_at: appState.opsGovernanceDetail.workflow_summary?.due_at || appState.opsGovernanceDetail.due_at, - disposition: appState.opsGovernanceDetail.workflow_summary?.disposition || appState.opsGovernanceDetail.disposition, - policy_labels: appState.opsGovernanceDetail.workflow_summary?.policy_labels || appState.opsGovernanceDetail.policy_labels || [], + case_id: opsState.opsGovernanceDetail.case_id, + case_type: opsState.opsGovernanceDetail.case_type, + target_type: opsState.opsGovernanceDetail.target_type, + target_id: opsState.opsGovernanceDetail.target_id, + severity: opsState.opsGovernanceDetail.severity, + reviewer_id: opsState.opsGovernanceDetail.reviewer_id, + owner_id: opsState.opsGovernanceDetail.workflow_summary?.owner_id || opsState.opsGovernanceDetail.owner_id, + summary: opsState.opsGovernanceDetail.summary, + description: opsState.opsGovernanceDetail.resolution_notes || opsState.opsGovernanceDetail.description, + status: opsState.opsGovernanceDetail.status, + account_id: opsState.opsGovernanceDetail.account_id, + due_at: opsState.opsGovernanceDetail.workflow_summary?.due_at || opsState.opsGovernanceDetail.due_at, + disposition: opsState.opsGovernanceDetail.workflow_summary?.disposition || opsState.opsGovernanceDetail.disposition, + policy_labels: opsState.opsGovernanceDetail.workflow_summary?.policy_labels || opsState.opsGovernanceDetail.policy_labels || [], }); - renderOpsSurface(); + OpsRenderRuntime.renderOpsSurface(); } async function escalateSupportIssue(issue) { - const accountId = els.opsAccountId?.value.trim() || activeReaderId(); + const accountId = dom.opsAccountId?.value.trim() || resolveActiveReaderId(); if (!accountId || !issue?.issue_id) { alert("缺少 account_id 或 support issue id。"); return; } - const restore = setBusy(els.opsCreateGovernanceCase, "升级中…"); + const restore = setBusy(dom.opsCreateGovernanceCase, "升级中…"); try { const payload = await api(`/v1/ops/accounts/${encodeURIComponent(accountId)}/governance/escalate-support`, { method: "POST", headers: opsGovernanceHeaders(), body: JSON.stringify({ issue_id: issue.issue_id, - reviewer_id: (els.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", + reviewer_id: (dom.opsGovernanceReviewerId?.value || "ops_web").trim() || "ops_web", }), }); - appState.opsGovernanceDetail = payload.case || null; + opsState.opsGovernanceDetail = payload.case || null; await refreshOpsAccountFlow(); if (payload.case?.case_id) { await openGovernanceCaseDetail(payload.case.case_id); @@ -944,9 +1206,9 @@ async function escalateSupportIssue(issue) { } async function grantOpsSubscription() { - const accountId = els.opsAccountId?.value.trim() || activeReaderId(); - const tierId = els.opsTierId?.value || "play_pass"; - const restore = setBusy(els.opsGrantSubscription, "授予中…"); + const accountId = dom.opsAccountId?.value.trim() || resolveActiveReaderId(); + const tierId = dom.opsTierId?.value || "play_pass"; + const restore = setBusy(dom.opsGrantSubscription, "授予中…"); try { await api("/v1/ops/subscriptions/grant", { method: "POST", @@ -966,14 +1228,14 @@ async function grantOpsSubscription() { } async function changeOpsSubscriptionState() { - const accountId = els.opsAccountId?.value.trim() || activeReaderId(); - const status = els.opsSubscriptionStatus?.value || "active"; - const current = appState.opsSubscriptionAudit?.subscriptions?.[0]; + const accountId = dom.opsAccountId?.value.trim() || resolveActiveReaderId(); + const status = dom.opsSubscriptionStatus?.value || "active"; + const current = opsState.opsSubscriptionAudit?.subscriptions?.[0]; if (!current?.subscription_id) { alert("当前 account 还没有 subscription 可更新。"); return; } - const restore = setBusy(els.opsChangeSubscriptionState, "更新中…"); + const restore = setBusy(dom.opsChangeSubscriptionState, "更新中…"); try { await api("/v1/ops/subscriptions/state", { method: "POST", @@ -991,10 +1253,10 @@ async function changeOpsSubscriptionState() { } async function grantOpsWallet() { - const accountId = els.opsAccountId?.value.trim() || activeReaderId(); - const walletType = els.opsWalletType?.value || "story_credits"; - const amount = Number(els.opsWalletAmount?.value || 10); - const restore = setBusy(els.opsGrantWallet, "充值中…"); + const accountId = dom.opsAccountId?.value.trim() || resolveActiveReaderId(); + const walletType = dom.opsWalletType?.value || "story_credits"; + const amount = Number(dom.opsWalletAmount?.value || 10); + const restore = setBusy(dom.opsGrantWallet, "充值中…"); try { await api("/v1/ops/wallets/grant", { method: "POST", @@ -1002,7 +1264,7 @@ async function grantOpsWallet() { account_id: accountId, wallet_type: walletType, amount, - tier_id: els.opsTierId?.value || null, + tier_id: dom.opsTierId?.value || null, }), }); await refreshOpsAccountFlow(); @@ -1014,10 +1276,10 @@ async function grantOpsWallet() { } async function debitOpsWallet() { - const accountId = els.opsAccountId?.value.trim() || activeReaderId(); - const walletType = els.opsWalletType?.value || "story_credits"; - const amount = Number(els.opsWalletAmount?.value || 10); - const restore = setBusy(els.opsDebitWallet, "扣减中…"); + const accountId = dom.opsAccountId?.value.trim() || resolveActiveReaderId(); + const walletType = dom.opsWalletType?.value || "story_credits"; + const amount = Number(dom.opsWalletAmount?.value || 10); + const restore = setBusy(dom.opsDebitWallet, "扣减中…"); try { await api("/v1/ops/wallets/debit", { method: "POST", @@ -1036,7 +1298,7 @@ async function debitOpsWallet() { } async function reconcileOpsSubscription() { - const current = appState.opsSubscriptionAudit?.subscriptions?.[0]; + const current = opsState.opsSubscriptionAudit?.subscriptions?.[0]; if (!current?.subscription_id) { alert("当前 account 还没有 subscription 可 reconcile。"); return; @@ -1045,7 +1307,7 @@ async function reconcileOpsSubscription() { await api(`/v1/ops/subscriptions/${encodeURIComponent(current.subscription_id)}/reconcile`, { method: "POST", body: JSON.stringify({ - requested_by: els.opsReviewerId?.value.trim() || "ops_web", + requested_by: dom.opsReviewerId?.value.trim() || "ops_web", }), }); await refreshOpsAccountFlow(); @@ -1055,7 +1317,7 @@ async function reconcileOpsSubscription() { } async function retryOpsSubscriptionPayment() { - const current = appState.opsSubscriptionAudit?.subscriptions?.[0]; + const current = opsState.opsSubscriptionAudit?.subscriptions?.[0]; if (!current?.subscription_id) { alert("当前 account 还没有 subscription 可 retry。"); return; @@ -1064,7 +1326,7 @@ async function retryOpsSubscriptionPayment() { await api(`/v1/ops/subscriptions/${encodeURIComponent(current.subscription_id)}/retry-payment`, { method: "POST", body: JSON.stringify({ - requested_by: els.opsReviewerId?.value.trim() || "ops_web", + requested_by: dom.opsReviewerId?.value.trim() || "ops_web", }), }); await refreshOpsAccountFlow(); @@ -1074,7 +1336,7 @@ async function retryOpsSubscriptionPayment() { } async function replayOpsBillingEvent() { - const eventId = (els.opsBillingEventId?.value || "").trim(); + const eventId = (dom.opsBillingEventId?.value || "").trim(); if (!eventId) { alert("先填写 billing event id。"); return; @@ -1083,7 +1345,7 @@ async function replayOpsBillingEvent() { await api(`/v1/ops/billing-events/${encodeURIComponent(eventId)}/replay`, { method: "POST", body: JSON.stringify({ - requested_by: els.opsReviewerId?.value.trim() || "ops_web", + requested_by: dom.opsReviewerId?.value.trim() || "ops_web", }), }); await refreshOpsAccountFlow(); @@ -1093,61 +1355,61 @@ async function replayOpsBillingEvent() { } async function updateSelectedOpsAlertStatus(status) { - if (!appState.selectedOpsAlertId) { + if (!opsState.selectedOpsAlertId) { alert("先选择一条 alert。"); return; } - const reviewerId = els.opsGovernanceReviewerId?.value.trim() || "ops_web"; - const accountId = currentOpsAlertFilters().accountId || appState.opsAlertDetail?.alert?.account_id || undefined; - await api(`/v1/ops/alerts/${encodeURIComponent(appState.selectedOpsAlertId)}/status`, { + const reviewerId = dom.opsGovernanceReviewerId?.value.trim() || "ops_web"; + const accountId = currentOpsAlertFilters().accountId || opsState.opsAlertDetail?.alert?.account_id || undefined; + await api(`/v1/ops/alerts/${encodeURIComponent(opsState.selectedOpsAlertId)}/status`, { method: "POST", body: JSON.stringify({ account_id: accountId, status, reviewer_id: reviewerId, - note: els.opsAlertNote?.value.trim() || null, + note: dom.opsAlertNote?.value.trim() || null, }), }); await refreshOpsAlerts(); - renderOpsSurface(); + OpsRenderRuntime.renderOpsSurface(); } async function openSelectedOpsAlertInvestigation() { const investigationRef = - appState.opsAlertDetail?.alert?.investigation_ref || - appState.opsAlertDetail?.investigation_bundle?.filters || + opsState.opsAlertDetail?.alert?.investigation_ref || + opsState.opsAlertDetail?.investigation_bundle?.filters || {}; if (!investigationRef.account_id && !investigationRef.world_version_id && !investigationRef.case_id) { alert("当前 alert 没有 investigation ref。"); return; } - if (els.opsInvestigationAccountId) { - els.opsInvestigationAccountId.value = investigationRef.account_id || ""; + if (dom.opsInvestigationAccountId) { + dom.opsInvestigationAccountId.value = investigationRef.account_id || ""; } - if (els.opsInvestigationWorldVersionId) { - els.opsInvestigationWorldVersionId.value = investigationRef.world_version_id || ""; + if (dom.opsInvestigationWorldVersionId) { + dom.opsInvestigationWorldVersionId.value = investigationRef.world_version_id || ""; } - if (els.opsInvestigationCaseId) { - els.opsInvestigationCaseId.value = investigationRef.case_id || ""; + if (dom.opsInvestigationCaseId) { + dom.opsInvestigationCaseId.value = investigationRef.case_id || ""; } await runOpsInvestigation(); - els.opsInvestigationSummary?.scrollIntoView({ behavior: "smooth", block: "start" }); + dom.opsInvestigationSummary?.scrollIntoView({ behavior: "smooth", block: "start" }); } async function runOpsWorkspaceAction(action) { const prefill = { ...((action && action.prefill) || {}) }; if (!action) return; - if (prefill.account_id && els.opsAccountId) { - els.opsAccountId.value = prefill.account_id; + if (prefill.account_id && dom.opsAccountId) { + dom.opsAccountId.value = prefill.account_id; } if (action.handler === "grant_wallet") { - if (els.opsWalletType && prefill.wallet_type) els.opsWalletType.value = prefill.wallet_type; - if (els.opsWalletAmount && prefill.amount !== undefined) els.opsWalletAmount.value = String(prefill.amount); + if (dom.opsWalletType && prefill.wallet_type) dom.opsWalletType.value = prefill.wallet_type; + if (dom.opsWalletAmount && prefill.amount !== undefined) dom.opsWalletAmount.value = String(prefill.amount); await grantOpsWallet(); return; } if (action.handler === "grant_subscription") { - if (els.opsTierId && prefill.tier_id) els.opsTierId.value = prefill.tier_id; + if (dom.opsTierId && prefill.tier_id) dom.opsTierId.value = prefill.tier_id; await grantOpsSubscription(); return; } @@ -1160,27 +1422,27 @@ async function runOpsWorkspaceAction(action) { return; } if (action.handler === "run_investigation") { - if (els.opsInvestigationAccountId) els.opsInvestigationAccountId.value = prefill.account_id || els.opsAccountId?.value || ""; - if (els.opsInvestigationWorldVersionId) els.opsInvestigationWorldVersionId.value = prefill.world_version_id || ""; - if (els.opsInvestigationCaseId) els.opsInvestigationCaseId.value = prefill.case_id || ""; + if (dom.opsInvestigationAccountId) dom.opsInvestigationAccountId.value = prefill.account_id || dom.opsAccountId?.value || ""; + if (dom.opsInvestigationWorldVersionId) dom.opsInvestigationWorldVersionId.value = prefill.world_version_id || ""; + if (dom.opsInvestigationCaseId) dom.opsInvestigationCaseId.value = prefill.case_id || ""; await runOpsInvestigation(); - els.opsInvestigationSummary?.scrollIntoView({ behavior: "smooth", block: "start" }); + dom.opsInvestigationSummary?.scrollIntoView({ behavior: "smooth", block: "start" }); return; } if (action.handler === "open_governance_case") { - if (prefill.account_id && els.opsAccountId) els.opsAccountId.value = prefill.account_id; - if (prefill.case_id && els.opsGovernanceCaseId) els.opsGovernanceCaseId.value = prefill.case_id; + if (prefill.account_id && dom.opsAccountId) dom.opsAccountId.value = prefill.account_id; + if (prefill.case_id && dom.opsGovernanceCaseId) dom.opsGovernanceCaseId.value = prefill.case_id; if (prefill.case_id) { await openGovernanceCaseDetail(prefill.case_id); - els.opsGovernanceDetail?.scrollIntoView({ behavior: "smooth", block: "start" }); + dom.opsGovernanceDetail?.scrollIntoView({ behavior: "smooth", block: "start" }); } return; } if (action.handler === "open_alert_feed") { - if (els.opsAlertAccountId) els.opsAlertAccountId.value = prefill.account_id || els.opsAccountId?.value || ""; + if (dom.opsAlertAccountId) dom.opsAlertAccountId.value = prefill.account_id || dom.opsAccountId?.value || ""; await refreshOpsAlerts(); - renderOpsSurface(); - els.opsAlertSummary?.scrollIntoView({ behavior: "smooth", block: "start" }); + OpsRenderRuntime.renderOpsSurface(); + dom.opsAlertSummary?.scrollIntoView({ behavior: "smooth", block: "start" }); } } @@ -1195,13 +1457,13 @@ async function runOpsReleaseWorkspaceAction(action) { } await api(`/v1/ops/world-versions/${encodeURIComponent(worldVersionId)}/publish`, { method: "POST", - body: JSON.stringify({ reviewer_id: els.opsGovernanceReviewerId?.value.trim() || "ops_web" }), + body: JSON.stringify({ reviewer_id: dom.opsGovernanceReviewerId?.value.trim() || "ops_web" }), }); await refreshOpsReleaseFlow(); return; } if (action.handler === "rollback_world") { - const worldId = prefill.world_id || appState.selectedOpsWorldId; + const worldId = prefill.world_id || opsState.selectedOpsWorldId; const targetWorldVersionId = prefill.target_world_version_id; if (!worldId || !targetWorldVersionId) { alert("当前 action 缺少 rollback 目标。"); @@ -1211,64 +1473,86 @@ async function runOpsReleaseWorkspaceAction(action) { method: "POST", body: JSON.stringify({ target_world_version_id: targetWorldVersionId, - reviewer_id: els.opsGovernanceReviewerId?.value.trim() || "ops_web", + reviewer_id: dom.opsGovernanceReviewerId?.value.trim() || "ops_web", }), }); await refreshOpsReleaseFlow(); return; } if (action.handler === "run_release_investigation") { - if (els.opsInvestigationWorldVersionId) { - els.opsInvestigationWorldVersionId.value = prefill.world_version_id || ""; + if (dom.opsInvestigationAccountId) { + dom.opsInvestigationAccountId.value = prefill.account_id || ""; } - if (els.opsInvestigationAccountId) { - els.opsInvestigationAccountId.value = ""; + if (dom.opsInvestigationWorldVersionId) { + dom.opsInvestigationWorldVersionId.value = prefill.world_version_id || ""; } - if (els.opsInvestigationCaseId) { - els.opsInvestigationCaseId.value = ""; + if (dom.opsInvestigationCaseId) { + dom.opsInvestigationCaseId.value = ""; } await runOpsInvestigation(); - els.opsInvestigationSummary?.scrollIntoView({ behavior: "smooth", block: "start" }); + dom.opsInvestigationSummary?.scrollIntoView({ behavior: "smooth", block: "start" }); return; } if (action.handler === "inspect_publish_blocker") { - els.opsReleaseWorkspaceDetails?.scrollIntoView({ behavior: "smooth", block: "start" }); + opsState.selectedOpsReleaseBlockerKey = prefill.blocker_key || null; + opsState.selectedOpsReleaseBlockerCheckKey = prefill.check_key || null; + OpsRenderRuntime.renderOpsSurface(); + highlightOpsReleaseWorkspaceTarget(); + return; + } + if (action.handler === "inspect_strategy_bundle_batch_validation" || action.handler === "inspect_cross_pack_quality") { + if (dom.opsCrossPackValidateStrategyBundle) { + dom.opsCrossPackValidateStrategyBundle.checked = Boolean(prefill.validate_strategy_bundle); + } + if (dom.opsCrossPackStrategyBundleId) { + dom.opsCrossPackStrategyBundleId.value = prefill.strategy_bundle_id || ""; + } + if (dom.opsCrossPackWeakestLimit && prefill.weakest_limit) { + dom.opsCrossPackWeakestLimit.value = String(prefill.weakest_limit); + } + await refreshOpsCrossPackQuality({ + validateStrategyBundle: Boolean(prefill.validate_strategy_bundle), + strategyBundleId: prefill.strategy_bundle_id || "", + weakestLimit: prefill.weakest_limit || dom.opsCrossPackWeakestLimit?.value || "3", + }); + OpsRenderRuntime.renderOpsSurface(); + dom.opsCrossPackQuality?.scrollIntoView({ behavior: "smooth", block: "start" }); } } function applyOpsNavigationStaleRefCleanup(staleRefs = {}) { if (staleRefs.alert) { - if (els.opsNavAlertId) els.opsNavAlertId.value = ""; - if (appState.selectedOpsAlertId === staleRefs.alert.ref_id) { - appState.selectedOpsAlertId = null; + if (dom.opsNavAlertId) dom.opsNavAlertId.value = ""; + if (opsState.selectedOpsAlertId === staleRefs.alert.ref_id) { + opsState.selectedOpsAlertId = null; } - appState.opsAlertDetail = null; + opsState.opsAlertDetail = null; } if (staleRefs.case) { - if (els.opsNavCaseId) els.opsNavCaseId.value = ""; - if (els.opsGovernanceCaseId) els.opsGovernanceCaseId.value = ""; - if (els.opsInvestigationCaseId) els.opsInvestigationCaseId.value = ""; - appState.opsGovernanceDetail = null; + if (dom.opsNavCaseId) dom.opsNavCaseId.value = ""; + if (dom.opsGovernanceCaseId) dom.opsGovernanceCaseId.value = ""; + if (dom.opsInvestigationCaseId) dom.opsInvestigationCaseId.value = ""; + opsState.opsGovernanceDetail = null; } if (staleRefs.world) { - if (els.opsNavWorldId) els.opsNavWorldId.value = ""; - if (els.opsReleaseWorldId) els.opsReleaseWorldId.value = ""; - if (appState.selectedOpsWorldId === staleRefs.world.ref_id) { - appState.selectedOpsWorldId = null; + if (dom.opsNavWorldId) dom.opsNavWorldId.value = ""; + if (dom.opsReleaseWorldId) dom.opsReleaseWorldId.value = ""; + if (opsState.selectedOpsWorldId === staleRefs.world.ref_id) { + opsState.selectedOpsWorldId = null; } - appState.opsReleaseWorkspace = null; + opsState.opsReleaseWorkspace = null; } if (staleRefs.world_version) { - if (els.opsInvestigationWorldVersionId) els.opsInvestigationWorldVersionId.value = ""; - if (appState.opsInvestigationBundle?.filters?.world_version_id === staleRefs.world_version.ref_id) { - appState.opsInvestigationBundle = null; + if (dom.opsInvestigationWorldVersionId) dom.opsInvestigationWorldVersionId.value = ""; + if (opsState.opsInvestigationBundle?.filters?.world_version_id === staleRefs.world_version.ref_id) { + opsState.opsInvestigationBundle = null; } } } async function clearOpsNavigationStaleRefs(action) { const prefill = { ...((action && action.prefill) || {}) }; - const staleRefs = { ...(prefill.stale_refs || appState.opsNavigationModel?.linked_context?.stale_refs || {}) }; + const staleRefs = { ...(prefill.stale_refs || opsState.opsNavigationModel?.linked_context?.stale_refs || {}) }; applyOpsNavigationStaleRefCleanup(staleRefs); await refreshOpsSurface({ scopes: ["account", "review_release", "alerts", "navigation", "investigation"], @@ -1278,7 +1562,7 @@ async function clearOpsNavigationStaleRefs(action) { async function resyncOpsNavigationContext(action) { const prefill = { ...((action && action.prefill) || {}) }; - const staleRefs = { ...(prefill.stale_refs || appState.opsNavigationModel?.linked_context?.stale_refs || {}) }; + const staleRefs = { ...(prefill.stale_refs || opsState.opsNavigationModel?.linked_context?.stale_refs || {}) }; applyOpsNavigationStaleRefCleanup(staleRefs); syncOpsNavigationContext( { @@ -1327,53 +1611,53 @@ async function runOpsNavigationTarget(target) { if (!target) return; syncOpsNavigationContext(prefill, { preserveExisting: false }); if (target.target_id === "account_workspace") { - if (prefill.account_id && els.opsAccountId) { - els.opsAccountId.value = prefill.account_id; + if (prefill.account_id && dom.opsAccountId) { + dom.opsAccountId.value = prefill.account_id; } await refreshOpsAccountFlow(); - els.opsAccountWorkspaceSummary?.scrollIntoView({ behavior: "smooth", block: "start" }); + dom.opsAccountWorkspaceSummary?.scrollIntoView({ behavior: "smooth", block: "start" }); return; } if (target.target_id === "release_workspace") { - if (prefill.world_id && els.opsReleaseWorldId) { - els.opsReleaseWorldId.value = prefill.world_id; + if (prefill.world_id && dom.opsReleaseWorldId) { + dom.opsReleaseWorldId.value = prefill.world_id; } - appState.selectedOpsWorldId = prefill.world_id || appState.selectedOpsWorldId; + opsState.selectedOpsWorldId = prefill.world_id || opsState.selectedOpsWorldId; await refreshOpsReleaseWorkspace(); - renderOpsSurface(); - els.opsReleaseWorkspaceSummary?.scrollIntoView({ behavior: "smooth", block: "start" }); + OpsRenderRuntime.renderOpsSurface(); + dom.opsReleaseWorkspaceSummary?.scrollIntoView({ behavior: "smooth", block: "start" }); return; } if (target.target_id === "governance_case") { if (prefill.case_id) { await openGovernanceCaseDetail(prefill.case_id); - els.opsGovernanceDetail?.scrollIntoView({ behavior: "smooth", block: "start" }); + dom.opsGovernanceDetail?.scrollIntoView({ behavior: "smooth", block: "start" }); } return; } if (target.target_id === "alert_detail") { - if (prefill.account_id && els.opsAlertAccountId) { - els.opsAlertAccountId.value = prefill.account_id; + if (prefill.account_id && dom.opsAlertAccountId) { + dom.opsAlertAccountId.value = prefill.account_id; } if (prefill.alert_id) { - appState.selectedOpsAlertId = prefill.alert_id; + opsState.selectedOpsAlertId = prefill.alert_id; } await refreshOpsAlerts(); - renderOpsSurface(); - els.opsAlertSummary?.scrollIntoView({ behavior: "smooth", block: "start" }); + OpsRenderRuntime.renderOpsSurface(); + dom.opsAlertSummary?.scrollIntoView({ behavior: "smooth", block: "start" }); return; } if (target.target_id === "investigation") { - if (els.opsInvestigationAccountId) els.opsInvestigationAccountId.value = prefill.account_id || ""; - if (els.opsInvestigationWorldVersionId) els.opsInvestigationWorldVersionId.value = prefill.world_version_id || ""; - if (els.opsInvestigationCaseId) els.opsInvestigationCaseId.value = prefill.case_id || ""; + if (dom.opsInvestigationAccountId) dom.opsInvestigationAccountId.value = prefill.account_id || ""; + if (dom.opsInvestigationWorldVersionId) dom.opsInvestigationWorldVersionId.value = prefill.world_version_id || ""; + if (dom.opsInvestigationCaseId) dom.opsInvestigationCaseId.value = prefill.case_id || ""; await runOpsInvestigation(); - els.opsInvestigationSummary?.scrollIntoView({ behavior: "smooth", block: "start" }); + dom.opsInvestigationSummary?.scrollIntoView({ behavior: "smooth", block: "start" }); } } async function followOpsNavigationRecommendation() { - const model = appState.opsNavigationModel; + const model = opsState.opsNavigationModel; if (!model?.escalation_summary?.recommended_target) { alert("当前没有推荐的 escalation target。"); return; @@ -1390,15 +1674,15 @@ async function followOpsNavigationRecommendation() { async function runOpsInvestigation(options = {}) { const token = options.token; - const accountId = (els.opsInvestigationAccountId?.value || "").trim() || (els.opsAccountId?.value || "").trim(); - const worldVersionId = (els.opsInvestigationWorldVersionId?.value || "").trim(); - const caseId = (els.opsInvestigationCaseId?.value || "").trim(); + const accountId = (dom.opsInvestigationAccountId?.value || "").trim() || (dom.opsAccountId?.value || "").trim(); + const worldVersionId = (dom.opsInvestigationWorldVersionId?.value || "").trim(); + const caseId = (dom.opsInvestigationCaseId?.value || "").trim(); if (!accountId && !worldVersionId && !caseId) { alert("请至少填写 account_id、world_version_id 或 case_id。"); return; } if (!options.silent) { - appState.opsInvestigationPinned = true; + opsState.opsInvestigationPinned = true; } const params = new URLSearchParams(); if (worldVersionId) params.set("world_version_id", worldVersionId); @@ -1415,16 +1699,16 @@ async function runOpsInvestigation(options = {}) { if (!isActiveOpsRefresh(token)) { return; } - appState.opsInvestigationBundle = payload; + opsState.opsInvestigationBundle = payload; if (!options.skipRender) { - renderOpsSurface(); + OpsRenderRuntime.renderOpsSurface(); } } async function exportOpsInvestigationTrace() { - const accountId = (els.opsInvestigationAccountId?.value || "").trim() || (els.opsAccountId?.value || "").trim(); - const worldVersionId = (els.opsInvestigationWorldVersionId?.value || "").trim(); - const caseId = (els.opsInvestigationCaseId?.value || "").trim(); + const accountId = (dom.opsInvestigationAccountId?.value || "").trim() || (dom.opsAccountId?.value || "").trim(); + const worldVersionId = (dom.opsInvestigationWorldVersionId?.value || "").trim(); + const caseId = (dom.opsInvestigationCaseId?.value || "").trim(); const params = new URLSearchParams(); if (accountId) params.set("account_id", accountId); if (worldVersionId) params.set("world_version_id", worldVersionId); @@ -1434,22 +1718,22 @@ async function exportOpsInvestigationTrace() { alert("请至少填写 account_id、world_version_id 或 case_id。"); return; } - appState.opsInvestigationBundle = await api(`/v1/ops/export/investigation-trace?${params.toString()}`); + opsState.opsInvestigationBundle = await api(`/v1/ops/export/investigation-trace?${params.toString()}`); downloadJsonFile( `investigation-trace-${caseId || worldVersionId || accountId || "export"}.json`, - appState.opsInvestigationBundle + opsState.opsInvestigationBundle ); - renderOpsSurface(); + OpsRenderRuntime.renderOpsSurface(); } async function revokeOpsEntitlement() { - const entitlementId = els.opsEntitlementId?.value.trim(); - const reason = els.opsEntitlementReason?.value.trim() || "manual_entitlement_revoke"; + const entitlementId = dom.opsEntitlementId?.value.trim(); + const reason = dom.opsEntitlementReason?.value.trim() || "manual_entitlement_revoke"; if (!entitlementId) { alert("请先填写要撤销的 entitlement_id。"); return; } - const restore = setBusy(els.opsRevokeEntitlement, "撤销中…"); + const restore = setBusy(dom.opsRevokeEntitlement, "撤销中…"); try { await api("/v1/ops/entitlements/revoke", { method: "POST", @@ -1465,3 +1749,88 @@ async function revokeOpsEntitlement() { restore(); } } + + return { + opsGovernanceHeaders, + opsRestoreHeaders, + showGovernanceOwnerDeniedBanner, + submitPromotionDecision, + submitRerankerPromotionDecision, + submitProviderRollout, + runDataIntegrityRepair, + submitAssistedGateConfig, + submitAssistedRerankConfig, + submitLearnedRollout, + createGovernanceCase, + updateGovernanceCaseStatus, + applyGovernanceRestriction, + releaseGovernanceRestriction, + assignGovernanceCase, + addGovernanceEvidence, + refreshGovernanceAuditExport, + createRuntimeBackup, + restoreRuntimeBackup, + runRecoveryDrill, + requestRuntimeRestore, + approveRuntimeRestore, + revokeRuntimeRestore, + executeRuntimeRestore, + retryAsyncJob, + resumeAsyncJob, + recoverAsyncJobIncidents, + enforceAsyncJobRetention, + runColdStartRecoveryDrill, + exportAsyncJobHandoffBundle, + acknowledgeAsyncJob, + shipRemoteArtifacts, + escalateHandoffSla, + enqueueNotificationRetry, + processNotificationRetry, + runLearnedTraining, + openGovernanceCaseDetail, + escalateSupportIssue, + grantOpsSubscription, + changeOpsSubscriptionState, + grantOpsWallet, + debitOpsWallet, + reconcileOpsSubscription, + retryOpsSubscriptionPayment, + replayOpsBillingEvent, + updateSelectedOpsAlertStatus, + openSelectedOpsAlertInvestigation, + runOpsWorkspaceAction, + runOpsReleaseWorkspaceAction, + applyOpsNavigationStaleRefCleanup, + clearOpsNavigationStaleRefs, + resyncOpsNavigationContext, + runOpsNavigationFollowUpAction, + runOpsNavigationTarget, + followOpsNavigationRecommendation, + runOpsInvestigation, + exportOpsInvestigationTrace, + revokeOpsEntitlement, + loadOpsQualityTraceDetail, + loadSelectedOpsReviewItem, + runOpsReviewHubAction + }; +})(); +function highlightOpsReleaseWorkspaceTarget() { + const blockerKey = opsState.selectedOpsReleaseBlockerKey; + const checkKey = opsState.selectedOpsReleaseBlockerCheckKey; + if (!blockerKey) { + dom.opsReleaseWorkspaceDetails?.scrollIntoView({ behavior: "smooth", block: "start" }); + return; + } + const selector = checkKey + ? `[data-release-blocker-key="${blockerKey}"][data-release-blocker-check-key="${checkKey}"]` + : `[data-release-blocker-key="${blockerKey}"]`; + const target = dom.opsReleaseWorkspaceDetails?.querySelector(selector) || dom.opsReleaseWorkspaceDetails; + target?.classList?.remove("is-highlighted"); + target?.scrollIntoView({ behavior: "smooth", block: "start" }); + target?.classList?.add("is-highlighted"); + if (typeof window !== "undefined") { + window.setTimeout(() => { + target?.classList?.remove("is-highlighted"); + }, 1400); + } +} diff --git a/src/narrativeos/web/ops_dom.js b/src/narrativeos/web/ops_dom.js new file mode 100644 index 0000000..d7599fc --- /dev/null +++ b/src/narrativeos/web/ops_dom.js @@ -0,0 +1,241 @@ +// Ops-scoped DOM registry. + +var OpsDOM = (() => ({ + opsRefresh: DOMShared.query("#ops-refresh"), + opsNavAccountId: DOMShared.query("#ops-nav-account-id"), + opsNavWorldId: DOMShared.query("#ops-nav-world-id"), + opsNavCaseId: DOMShared.query("#ops-nav-case-id"), + opsNavAlertId: DOMShared.query("#ops-nav-alert-id"), + opsSyncNavigation: DOMShared.query("#ops-sync-navigation"), + opsFollowRecommendation: DOMShared.query("#ops-follow-recommendation"), + opsNavigationSummary: DOMShared.query("#ops-navigation-summary"), + opsNavigationTargets: DOMShared.query("#ops-navigation-targets"), + opsNavigationActions: DOMShared.query("#ops-navigation-actions"), + opsPendingCount: DOMShared.query("#ops-pending-count"), + opsPublishedWorlds: DOMShared.query("#ops-published-worlds"), + opsTotalCost: DOMShared.query("#ops-total-cost"), + opsReviewQueue: DOMShared.query("#ops-review-queue"), + opsWorldStatus: DOMShared.query("#ops-world-status"), + opsReleaseWorldId: DOMShared.query("#ops-release-world-id"), + opsRefreshReleaseWorkspace: DOMShared.query("#ops-refresh-release-workspace"), + opsReleaseWorkspaceSummary: DOMShared.query("#ops-release-workspace-summary"), + opsReleaseWorkspaceActions: DOMShared.query("#ops-release-workspace-actions"), + opsReleaseWorkspaceTimeline: DOMShared.query("#ops-release-workspace-timeline"), + opsReleaseWorkspaceDetails: DOMShared.query("#ops-release-workspace-details"), + opsReviewHistory: DOMShared.query("#ops-review-history"), + opsQualityTrend: DOMShared.query("#ops-quality-trend"), + opsSchemaLifecycle: DOMShared.query("#ops-schema-lifecycle"), + opsDataIntegrityActions: DOMShared.query("#ops-data-integrity-actions"), + opsRunDataIntegrityDryRun: DOMShared.query("#ops-run-data-integrity-dry-run"), + opsApplyDataIntegrityRepair: DOMShared.query("#ops-apply-data-integrity-repair"), + opsDataIntegrity: DOMShared.query("#ops-data-integrity"), + opsBackupLabel: DOMShared.query("#ops-backup-label"), + opsRestorePath: DOMShared.query("#ops-restore-path"), + opsRestoreRequestId: DOMShared.query("#ops-restore-request-id"), + opsRestoreRequesterId: DOMShared.query("#ops-restore-requester-id"), + opsRestoreApproverId: DOMShared.query("#ops-restore-approver-id"), + opsRestoreReason: DOMShared.query("#ops-restore-reason"), + opsCreateRuntimeBackup: DOMShared.query("#ops-create-runtime-backup"), + opsRestoreRuntimeBackup: DOMShared.query("#ops-restore-runtime-backup"), + opsRunRecoveryDrill: DOMShared.query("#ops-run-recovery-drill"), + opsRequestRuntimeRestore: DOMShared.query("#ops-request-runtime-restore"), + opsApproveRuntimeRestore: DOMShared.query("#ops-approve-runtime-restore"), + opsRevokeRuntimeRestore: DOMShared.query("#ops-revoke-runtime-restore"), + opsExecuteRuntimeRestore: DOMShared.query("#ops-execute-runtime-restore"), + opsDeploymentHealthGate: DOMShared.query("#ops-deployment-health-gate"), + opsPreflightVerification: DOMShared.query("#ops-preflight-verification"), + opsDeploymentRunbook: DOMShared.query("#ops-deployment-runbook"), + opsIncidentPlaybook: DOMShared.query("#ops-incident-playbook"), + opsAsyncJobId: DOMShared.query("#ops-async-job-id"), + opsAsyncJobNote: DOMShared.query("#ops-async-job-note"), + opsNotificationReceiptId: DOMShared.query("#ops-notification-receipt-id"), + opsExportHandoffBundle: DOMShared.query("#ops-export-handoff-bundle"), + opsAcknowledgeAsyncJob: DOMShared.query("#ops-acknowledge-async-job"), + opsShipRemoteArtifacts: DOMShared.query("#ops-ship-remote-artifacts"), + opsEscalateHandoffSla: DOMShared.query("#ops-escalate-handoff-sla"), + opsEnqueueNotificationRetry: DOMShared.query("#ops-enqueue-notification-retry"), + opsProcessNotificationRetry: DOMShared.query("#ops-process-notification-retry"), + opsRetryAsyncJob: DOMShared.query("#ops-retry-async-job"), + opsResumeAsyncJob: DOMShared.query("#ops-resume-async-job"), + opsRecoverAsyncJobs: DOMShared.query("#ops-recover-async-jobs"), + opsEnforceAsyncRetention: DOMShared.query("#ops-enforce-async-retention"), + opsRunColdStartDrill: DOMShared.query("#ops-run-cold-start-drill"), + opsAsyncJobSummary: DOMShared.query("#ops-async-job-summary"), + opsAsyncJobBootReconcile: DOMShared.query("#ops-async-job-boot-reconcile"), + opsAsyncJobIncidents: DOMShared.query("#ops-async-job-incidents"), + opsAsyncJobArtifactRetention: DOMShared.query("#ops-async-job-artifact-retention"), + opsAsyncJobOperatorHistory: DOMShared.query("#ops-async-job-operator-history"), + opsAsyncJobHandoffBundle: DOMShared.query("#ops-async-job-handoff-bundle"), + opsAsyncJobAdapterValidation: DOMShared.query("#ops-async-job-adapter-validation"), + opsAsyncJobAdapterHealthProbe: DOMShared.query("#ops-async-job-adapter-health-probe"), + opsAsyncJobNotificationReceipts: DOMShared.query("#ops-async-job-notification-receipts"), + opsAsyncNotificationRetryQueue: DOMShared.query("#ops-async-job-notification-retry-queue"), + opsAsyncNotificationDeadLetterQueue: DOMShared.query("#ops-async-job-dead-letter-queue"), + opsAsyncRetryOutcomeDashboard: DOMShared.query("#ops-async-job-retry-outcome-dashboard"), + opsAsyncJobs: DOMShared.query("#ops-async-jobs"), + opsRuntimeIncidentSnapshot: DOMShared.query("#ops-runtime-incident-snapshot"), + opsRuntimeReceipts: DOMShared.query("#ops-runtime-receipts"), + opsProviderRouting: DOMShared.query("#ops-provider-routing"), + opsProviderRollout: DOMShared.query("#ops-provider-rollout"), + opsProviderRolloutReviewerId: DOMShared.query("#ops-provider-rollout-reviewer-id"), + opsProviderRolloutReason: DOMShared.query("#ops-provider-rollout-reason"), + opsProviderRolloutBucket: DOMShared.query("#ops-provider-rollout-bucket"), + opsProviderRolloutWorldAllowlist: DOMShared.query("#ops-provider-rollout-world-allowlist"), + opsProviderCandidateCanary: DOMShared.query("#ops-provider-candidate-canary"), + opsProviderCandidateActivate: DOMShared.query("#ops-provider-candidate-activate"), + opsProviderCandidateRollback: DOMShared.query("#ops-provider-candidate-rollback"), + opsProviderRendererCanary: DOMShared.query("#ops-provider-renderer-canary"), + opsProviderRendererActivate: DOMShared.query("#ops-provider-renderer-activate"), + opsProviderRendererRollback: DOMShared.query("#ops-provider-renderer-rollback"), + opsProviderRuntimeMetrics: DOMShared.query("#ops-provider-runtime-metrics"), + opsStoryBootstrapWorldSummary: DOMShared.query("#ops-story-bootstrap-world-summary"), + opsStoryBootstrapWorldDetail: DOMShared.query("#ops-story-bootstrap-world-detail"), + opsMeterList: DOMShared.query("#ops-meter-list"), + opsAccountId: DOMShared.query("#ops-account-id"), + opsWalletType: DOMShared.query("#ops-wallet-type"), + opsTierId: DOMShared.query("#ops-tier-id"), + opsWalletAmount: DOMShared.query("#ops-wallet-amount"), + opsSubscriptionStatus: DOMShared.query("#ops-subscription-status"), + opsEntitlementId: DOMShared.query("#ops-entitlement-id"), + opsEntitlementReason: DOMShared.query("#ops-entitlement-reason"), + opsBillingEventId: DOMShared.query("#ops-billing-event-id"), + opsGrantSubscription: DOMShared.query("#ops-grant-subscription"), + opsChangeSubscriptionState: DOMShared.query("#ops-change-subscription-state"), + opsGrantWallet: DOMShared.query("#ops-grant-wallet"), + opsDebitWallet: DOMShared.query("#ops-debit-wallet"), + opsRevokeEntitlement: DOMShared.query("#ops-revoke-entitlement"), + opsReconcileSubscription: DOMShared.query("#ops-reconcile-subscription"), + opsRetrySubscriptionPayment: DOMShared.query("#ops-retry-subscription-payment"), + opsReplayBillingEvent: DOMShared.query("#ops-replay-billing-event"), + opsSubscriptionAudit: DOMShared.query("#ops-subscription-audit"), + opsSubscriptionTimeline: DOMShared.query("#ops-subscription-timeline"), + opsAccountWorkspaceSummary: DOMShared.query("#ops-account-workspace-summary"), + opsAccountWorkspaceActions: DOMShared.query("#ops-account-workspace-actions"), + opsAccountWorkspaceTimeline: DOMShared.query("#ops-account-workspace-timeline"), + opsAccountDetail: DOMShared.query("#ops-account-detail"), + opsAccountActivity: DOMShared.query("#ops-account-activity"), + opsSupportSummary: DOMShared.query("#ops-support-summary"), + opsSupportIssues: DOMShared.query("#ops-support-issues"), + opsAlertAccountId: DOMShared.query("#ops-alert-account-id"), + opsAlertStatusFilter: DOMShared.query("#ops-alert-status-filter"), + opsAlertSeverityFilter: DOMShared.query("#ops-alert-severity-filter"), + opsAlertNote: DOMShared.query("#ops-alert-note"), + opsRefreshAlerts: DOMShared.query("#ops-refresh-alerts"), + opsAcknowledgeAlert: DOMShared.query("#ops-acknowledge-alert"), + opsResolveAlert: DOMShared.query("#ops-resolve-alert"), + opsOpenAlertInvestigation: DOMShared.query("#ops-open-alert-investigation"), + opsAlertSummary: DOMShared.query("#ops-alert-summary"), + opsAlertFeed: DOMShared.query("#ops-alert-feed"), + opsAlertDetail: DOMShared.query("#ops-alert-detail"), + opsGovernanceCaseId: DOMShared.query("#ops-governance-case-id"), + opsGovernanceCaseType: DOMShared.query("#ops-governance-case-type"), + opsGovernanceTargetType: DOMShared.query("#ops-governance-target-type"), + opsGovernanceTargetId: DOMShared.query("#ops-governance-target-id"), + opsGovernanceSeverity: DOMShared.query("#ops-governance-severity"), + opsGovernanceReviewerId: DOMShared.query("#ops-governance-reviewer-id"), + opsGovernanceOwnerId: DOMShared.query("#ops-governance-owner-id"), + opsGovernanceSummaryInput: DOMShared.query("#ops-governance-summary-input"), + opsGovernanceNotes: DOMShared.query("#ops-governance-notes"), + opsGovernanceStatus: DOMShared.query("#ops-governance-status"), + opsGovernanceDueAt: DOMShared.query("#ops-governance-due-at"), + opsGovernancePolicyLabels: DOMShared.query("#ops-governance-policy-labels"), + opsGovernanceDisposition: DOMShared.query("#ops-governance-disposition"), + opsGovernanceEvidenceTitle: DOMShared.query("#ops-governance-evidence-title"), + opsGovernanceEvidencePreview: DOMShared.query("#ops-governance-evidence-preview"), + opsGovernanceRestrictionType: DOMShared.query("#ops-governance-restriction-type"), + opsGovernanceRestrictionExpiresAt: DOMShared.query("#ops-governance-restriction-expires-at"), + opsCreateGovernanceCase: DOMShared.query("#ops-create-governance-case"), + opsAssignGovernanceCase: DOMShared.query("#ops-assign-governance-case"), + opsAddGovernanceEvidence: DOMShared.query("#ops-add-governance-evidence"), + opsUpdateGovernanceCase: DOMShared.query("#ops-update-governance-case"), + opsApplyGovernanceRestriction: DOMShared.query("#ops-apply-governance-restriction"), + opsReleaseGovernanceRestriction: DOMShared.query("#ops-release-governance-restriction"), + opsExportGovernanceAudit: DOMShared.query("#ops-export-governance-audit"), + opsGovernanceSummary: DOMShared.query("#ops-governance-summary"), + opsGovernanceCases: DOMShared.query("#ops-governance-cases"), + opsGovernanceExport: DOMShared.query("#ops-governance-export"), + opsGovernanceDetail: DOMShared.query("#ops-governance-detail"), + opsAccountAuditSummary: DOMShared.query("#ops-account-audit-summary"), + opsAccountAuditTrail: DOMShared.query("#ops-account-audit-trail"), + opsInvestigationAccountId: DOMShared.query("#ops-investigation-account-id"), + opsInvestigationWorldVersionId: DOMShared.query("#ops-investigation-world-version-id"), + opsInvestigationCaseId: DOMShared.query("#ops-investigation-case-id"), + opsRunInvestigation: DOMShared.query("#ops-run-investigation"), + opsExportInvestigationTrace: DOMShared.query("#ops-export-investigation-trace"), + opsInvestigationSummary: DOMShared.query("#ops-investigation-summary"), + opsInvestigationTimeline: DOMShared.query("#ops-investigation-timeline"), + opsInvestigationEvidence: DOMShared.query("#ops-investigation-evidence"), + opsEvalMetrics: DOMShared.query("#ops-eval-metrics"), + opsCrossPackStrategyBundleId: DOMShared.query("#ops-cross-pack-strategy-bundle-id"), + opsCrossPackWeakestLimit: DOMShared.query("#ops-cross-pack-weakest-limit"), + opsCrossPackValidateStrategyBundle: DOMShared.query("#ops-cross-pack-validate-strategy-bundle"), + opsRefreshCrossPackQuality: DOMShared.query("#ops-refresh-cross-pack-quality"), + opsCrossPackQuality: DOMShared.query("#ops-cross-pack-quality"), + opsLearnedDashboard: DOMShared.query("#ops-learned-dashboard"), + opsLearnedImpact: DOMShared.query("#ops-learned-impact"), + opsLearnedCadence: DOMShared.query("#ops-learned-cadence"), + opsLearnedAssistedGate: DOMShared.query("#ops-learned-assisted-gate"), + opsLearnedAssistedRerank: DOMShared.query("#ops-learned-assisted-rerank"), + opsLearnedReviewQuality: DOMShared.query("#ops-learned-review-quality"), + opsAssistedGateReviewerId: DOMShared.query("#ops-assisted-gate-reviewer-id"), + opsAssistedGateReason: DOMShared.query("#ops-assisted-gate-reason"), + opsAssistedGateBucket: DOMShared.query("#ops-assisted-gate-bucket"), + opsAssistedGateConfidence: DOMShared.query("#ops-assisted-gate-confidence"), + opsAssistedGateWorldAllowlist: DOMShared.query("#ops-assisted-gate-world-allowlist"), + opsSetAssistedShadow: DOMShared.query("#ops-set-assisted-shadow"), + opsSetAssistedActive: DOMShared.query("#ops-set-assisted-active"), + opsDisableAssistedGate: DOMShared.query("#ops-disable-assisted-gate"), + opsAssistedRerankReviewerId: DOMShared.query("#ops-assisted-rerank-reviewer-id"), + opsAssistedRerankReason: DOMShared.query("#ops-assisted-rerank-reason"), + opsAssistedRerankBucket: DOMShared.query("#ops-assisted-rerank-bucket"), + opsAssistedRerankConfidence: DOMShared.query("#ops-assisted-rerank-confidence"), + opsAssistedRerankCandidateWindow: DOMShared.query("#ops-assisted-rerank-candidate-window"), + opsAssistedRerankMaxScoreGap: DOMShared.query("#ops-assisted-rerank-max-score-gap"), + opsAssistedRerankWorldAllowlist: DOMShared.query("#ops-assisted-rerank-world-allowlist"), + opsSetAssistedRerankShadow: DOMShared.query("#ops-set-assisted-rerank-shadow"), + opsSetAssistedRerankActive: DOMShared.query("#ops-set-assisted-rerank-active"), + opsDisableAssistedRerank: DOMShared.query("#ops-disable-assisted-rerank"), + opsRunEvaluatorTraining: DOMShared.query("#ops-run-evaluator-training"), + opsRunRerankerTraining: DOMShared.query("#ops-run-reranker-training"), + opsRunBothTraining: DOMShared.query("#ops-run-both-training"), + opsLearnedTraining: DOMShared.query("#ops-learned-training"), + opsLearnedEvidence: DOMShared.query("#ops-learned-evidence"), + opsLearnedCompare: DOMShared.query("#ops-learned-compare"), + opsLearnedRollout: DOMShared.query("#ops-learned-rollout"), + opsLearnedDataOps: DOMShared.query("#ops-learned-data-ops"), + opsLearnedPromotion: DOMShared.query("#ops-learned-promotion"), + opsLearnedRerankerPromotion: DOMShared.query("#ops-learned-reranker-promotion"), + opsPromotionReviewerId: DOMShared.query("#ops-promotion-reviewer-id"), + opsPromotionReason: DOMShared.query("#ops-promotion-reason"), + opsApprovePromotion: DOMShared.query("#ops-approve-promotion"), + opsRevokePromotion: DOMShared.query("#ops-revoke-promotion"), + opsRerankerPromotionReviewerId: DOMShared.query("#ops-reranker-promotion-reviewer-id"), + opsRerankerPromotionReason: DOMShared.query("#ops-reranker-promotion-reason"), + opsApproveRerankerPromotion: DOMShared.query("#ops-approve-reranker-promotion"), + opsRevokeRerankerPromotion: DOMShared.query("#ops-revoke-reranker-promotion"), + opsLearnedWorlds: DOMShared.query("#ops-learned-worlds"), + opsLearnedIssues: DOMShared.query("#ops-learned-issues"), + opsLearnedDetail: DOMShared.query("#ops-learned-detail"), + opsReviewSampleBacklog: DOMShared.query("#ops-review-sample-backlog"), + opsPairCoverageBacklog: DOMShared.query("#ops-pair-coverage-backlog"), + opsReviewCaptureContext: DOMShared.query("#ops-review-capture-context"), + opsLastActionImpact: DOMShared.query("#ops-last-action-impact"), + opsReviewerId: DOMShared.query("#ops-reviewer-id"), + opsReviewScore: DOMShared.query("#ops-review-score"), + opsReviewIssueCodes: DOMShared.query("#ops-review-issue-codes"), + opsReviewNotes: DOMShared.query("#ops-review-notes"), + opsReviewWouldContinue: DOMShared.query("#ops-review-would-continue"), + opsReviewWouldPay: DOMShared.query("#ops-review-would-pay"), + opsSubmitReviewCapture: DOMShared.query("#ops-submit-review-capture"), + opsPreferenceLeftRevisionId: DOMShared.query("#ops-preference-left-revision-id"), + opsPreferenceRightRevisionId: DOMShared.query("#ops-preference-right-revision-id"), + opsPreferencePreferredRevisionId: DOMShared.query("#ops-preference-preferred-revision-id"), + opsPreferenceStrength: DOMShared.query("#ops-preference-strength"), + opsPreferenceNotes: DOMShared.query("#ops-preference-notes"), + opsSubmitPreferenceCapture: DOMShared.query("#ops-submit-preference-capture"), + opsPreferenceSamples: DOMShared.query("#ops-preference-samples"), + opsRankingRevisionIds: DOMShared.query("#ops-ranking-revision-ids"), + opsRankingNotes: DOMShared.query("#ops-ranking-notes"), + opsSubmitRankingCapture: DOMShared.query("#ops-submit-ranking-capture"), + opsRankingSamples: DOMShared.query("#ops-ranking-samples"), +}))(); diff --git a/src/narrativeos/web/ops_refresh.js b/src/narrativeos/web/ops_refresh.js index a1b0e33..ed42a56 100644 --- a/src/narrativeos/web/ops_refresh.js +++ b/src/narrativeos/web/ops_refresh.js @@ -1,14 +1,115 @@ // Ops refresh orchestration extracted from app.js. +var OpsRefreshRuntime = (() => { + const dom = OpsDOM; + const { api } = UIShared; + const { latestAsyncJob } = OpsAccessors; + +function resolveActiveReaderId() { + if (typeof ReaderRuntime !== "undefined" && typeof ReaderRuntime.activeReaderId === "function") { + return ReaderRuntime.activeReaderId(); + } + return readerState.readerId || ""; +} + function currentOpsNavigationContext() { return { - account_id: (els.opsNavAccountId?.value || "").trim() || "", - world_id: (els.opsNavWorldId?.value || "").trim() || "", - case_id: (els.opsNavCaseId?.value || "").trim() || "", - alert_id: (els.opsNavAlertId?.value || "").trim() || "", + account_id: (dom.opsNavAccountId?.value || "").trim() || "", + world_id: (dom.opsNavWorldId?.value || "").trim() || "", + case_id: (dom.opsNavCaseId?.value || "").trim() || "", + alert_id: (dom.opsNavAlertId?.value || "").trim() || "", }; } +function dedupeOpsReviewItems(items) { + const seen = new Set(); + return (items || []).filter((item) => { + const reviewItemId = String(item?.review_item_id || "").trim(); + if (!reviewItemId || seen.has(reviewItemId)) { + return false; + } + seen.add(reviewItemId); + return true; + }); +} + +function normalizeLegacyReviewQueueItems(reviews) { + return (reviews || []).map((review) => { + const worldVersionId = String(review?.asset_id || review?.world_version_id || "").trim(); + if (!worldVersionId) { + return null; + } + return { + review_item_id: `ops_review::world_version_review::${worldVersionId}`, + source_type: "world_version_review", + source_id: worldVersionId, + queue: "content_release", + status: review?.status || "new", + severity: "medium", + headline: `${worldVersionId} · 发布审阅`, + summary: review?.notes || review?.status || "", + recommended_action: "review_candidate", + account_id: null, + world_id: null, + world_version_id: worldVersionId, + linked_entities: [], + allowed_actions: ["assign_to_me", "mark_in_review", "approve", "needs_changes", "block"], + due_at: review?.updated_at || null, + sla_bucket: "backlog", + owner_id: null, + reviewer_id: review?.reviewer_id || null, + }; + }).filter(Boolean); +} + +function normalizeOpsReviewHubPayload(reviewHubPayload, queuePayload) { + const reviewHub = reviewHubPayload || {}; + const directItems = Array.isArray(reviewHub.items) ? reviewHub.items : []; + const triageItems = Object.values(reviewHub.triage?.by_queue || {}).flat(); + const legacyItems = normalizeLegacyReviewQueueItems(queuePayload?.reviews || []); + const normalizedItems = dedupeOpsReviewItems( + directItems.length ? directItems : [...triageItems, ...legacyItems] + ); + return { + ...reviewHub, + items: normalizedItems, + }; +} + +function buildCrossPackQualityUrl(options = {}) { + const strategyBundleId = String( + options.strategyBundleId !== undefined + ? options.strategyBundleId + : (dom.opsCrossPackStrategyBundleId?.value || "") + ).trim(); + const weakestLimit = String( + options.weakestLimit !== undefined + ? options.weakestLimit + : (dom.opsCrossPackWeakestLimit?.value || "") + ).trim(); + const shouldValidate = + options.validateStrategyBundle !== undefined + ? Boolean(options.validateStrategyBundle) + : Boolean(dom.opsCrossPackValidateStrategyBundle?.checked); + const params = new URLSearchParams(); + if (shouldValidate) { + params.set("validate_strategy_bundle", "true"); + } + if (strategyBundleId) params.set("strategy_bundle_id", strategyBundleId); + if (weakestLimit) params.set("weakest_limit", weakestLimit); + return `/v1/ops/cross-pack-quality${params.toString() ? `?${params.toString()}` : ""}`; +} + +async function refreshOpsCrossPackQuality(options = {}) { + const token = options.token; + const payload = await api(buildCrossPackQualityUrl(options)); + if (!isActiveOpsRefresh(token)) { + return payload; + } + opsState.opsCrossPackQuality = payload; + return payload; +} + const OPS_REFRESH_SCOPE_ALL = [ "review_release", "runtime", @@ -31,13 +132,13 @@ function normalizeOpsRefreshScopes(scopes) { } function isActiveOpsRefresh(token) { - return token === undefined || token === null || token === appState.opsRefreshRequestId; + return token === undefined || token === null || token === opsState.opsRefreshRequestId; } function syncOpsNavigationContext(prefill = {}, options = {}) { const preserveExisting = Boolean(options.preserveExisting); if ([prefill.account_id, prefill.world_id, prefill.case_id, prefill.alert_id].some(Boolean)) { - appState.opsNavigationPinned = true; + opsState.opsNavigationPinned = true; } const merged = { account_id: prefill.account_id ?? currentOpsNavigationContext().account_id, @@ -46,52 +147,52 @@ function syncOpsNavigationContext(prefill = {}, options = {}) { alert_id: prefill.alert_id ?? currentOpsNavigationContext().alert_id, world_version_id: prefill.world_version_id, }; - if (els.opsNavAccountId && (!preserveExisting || !els.opsNavAccountId.value.trim() || prefill.account_id !== undefined)) { - els.opsNavAccountId.value = merged.account_id || ""; + if (dom.opsNavAccountId && (!preserveExisting || !dom.opsNavAccountId.value.trim() || prefill.account_id !== undefined)) { + dom.opsNavAccountId.value = merged.account_id || ""; } - if (els.opsNavWorldId && (!preserveExisting || !els.opsNavWorldId.value.trim() || prefill.world_id !== undefined)) { - els.opsNavWorldId.value = merged.world_id || ""; + if (dom.opsNavWorldId && (!preserveExisting || !dom.opsNavWorldId.value.trim() || prefill.world_id !== undefined)) { + dom.opsNavWorldId.value = merged.world_id || ""; } - if (els.opsNavCaseId && (!preserveExisting || !els.opsNavCaseId.value.trim() || prefill.case_id !== undefined)) { - els.opsNavCaseId.value = merged.case_id || ""; + if (dom.opsNavCaseId && (!preserveExisting || !dom.opsNavCaseId.value.trim() || prefill.case_id !== undefined)) { + dom.opsNavCaseId.value = merged.case_id || ""; } - if (els.opsNavAlertId && (!preserveExisting || !els.opsNavAlertId.value.trim() || prefill.alert_id !== undefined)) { - els.opsNavAlertId.value = merged.alert_id || ""; + if (dom.opsNavAlertId && (!preserveExisting || !dom.opsNavAlertId.value.trim() || prefill.alert_id !== undefined)) { + dom.opsNavAlertId.value = merged.alert_id || ""; } - if (els.opsAccountId && (merged.account_id || prefill.account_id !== undefined)) { - els.opsAccountId.value = merged.account_id || ""; + if (dom.opsAccountId && (merged.account_id || prefill.account_id !== undefined)) { + dom.opsAccountId.value = merged.account_id || ""; } if ( - els.opsAlertAccountId && + dom.opsAlertAccountId && (merged.account_id || prefill.account_id !== undefined) && - (!preserveExisting || !els.opsAlertAccountId.value.trim() || prefill.account_id !== undefined) + (!preserveExisting || !dom.opsAlertAccountId.value.trim() || prefill.account_id !== undefined) ) { - els.opsAlertAccountId.value = merged.account_id || ""; + dom.opsAlertAccountId.value = merged.account_id || ""; } if ( - els.opsInvestigationAccountId && + dom.opsInvestigationAccountId && (merged.account_id || prefill.account_id !== undefined) && - (!preserveExisting || !els.opsInvestigationAccountId.value.trim() || prefill.account_id !== undefined) + (!preserveExisting || !dom.opsInvestigationAccountId.value.trim() || prefill.account_id !== undefined) ) { - els.opsInvestigationAccountId.value = merged.account_id || ""; + dom.opsInvestigationAccountId.value = merged.account_id || ""; } if (merged.world_id || prefill.world_id !== undefined) { - appState.selectedOpsWorldId = merged.world_id || null; - if (els.opsReleaseWorldId && (!preserveExisting || !els.opsReleaseWorldId.value.trim() || prefill.world_id !== undefined)) { - els.opsReleaseWorldId.value = merged.world_id || ""; + opsState.selectedOpsWorldId = merged.world_id || null; + if (dom.opsReleaseWorldId && (!preserveExisting || !dom.opsReleaseWorldId.value.trim() || prefill.world_id !== undefined)) { + dom.opsReleaseWorldId.value = merged.world_id || ""; } } - if (els.opsGovernanceCaseId && (merged.case_id || prefill.case_id !== undefined)) { - els.opsGovernanceCaseId.value = merged.case_id || ""; + if (dom.opsGovernanceCaseId && (merged.case_id || prefill.case_id !== undefined)) { + dom.opsGovernanceCaseId.value = merged.case_id || ""; } - if (els.opsInvestigationCaseId && (merged.case_id || prefill.case_id !== undefined)) { - els.opsInvestigationCaseId.value = merged.case_id || ""; + if (dom.opsInvestigationCaseId && (merged.case_id || prefill.case_id !== undefined)) { + dom.opsInvestigationCaseId.value = merged.case_id || ""; } - if (els.opsInvestigationWorldVersionId && (merged.world_version_id || prefill.world_version_id !== undefined)) { - els.opsInvestigationWorldVersionId.value = merged.world_version_id || ""; + if (dom.opsInvestigationWorldVersionId && (merged.world_version_id || prefill.world_version_id !== undefined)) { + dom.opsInvestigationWorldVersionId.value = merged.world_version_id || ""; } if (merged.alert_id || prefill.alert_id !== undefined) { - appState.selectedOpsAlertId = merged.alert_id || null; + opsState.selectedOpsAlertId = merged.alert_id || null; } } @@ -105,7 +206,7 @@ async function refreshOpsNavigationModel(options = {}) { if (context.alert_id) params.set("alert_id", context.alert_id); if (![context.account_id, context.world_id, context.case_id, context.alert_id].some(Boolean)) { if (isActiveOpsRefresh(token)) { - appState.opsNavigationModel = null; + opsState.opsNavigationModel = null; } return; } @@ -113,20 +214,20 @@ async function refreshOpsNavigationModel(options = {}) { if (!isActiveOpsRefresh(token)) { return; } - appState.opsNavigationModel = payload; - syncOpsNavigationContext(appState.opsNavigationModel.active_context || {}, { preserveExisting: false }); + opsState.opsNavigationModel = payload; + syncOpsNavigationContext(opsState.opsNavigationModel.active_context || {}, { preserveExisting: false }); } async function refreshOpsSubscriptionAudit(options = {}) { const token = options.token; - const accountId = els.opsAccountId?.value.trim() || activeReaderId(); + const accountId = dom.opsAccountId?.value.trim() || resolveActiveReaderId(); if (!accountId) { if (isActiveOpsRefresh(token)) { - appState.opsSubscriptionAudit = null; - appState.opsAccountWorkspace = null; - appState.opsGovernanceSnapshot = null; - appState.opsGovernanceExport = null; - appState.opsGovernanceDetail = null; + opsState.opsSubscriptionAudit = null; + opsState.opsAccountWorkspace = null; + opsState.opsGovernanceSnapshot = null; + opsState.opsGovernanceExport = null; + opsState.opsGovernanceDetail = null; } return; } @@ -141,7 +242,7 @@ async function refreshOpsSubscriptionAudit(options = {}) { if (!isActiveOpsRefresh(token)) { return; } - appState.opsSubscriptionAudit = { + opsState.opsSubscriptionAudit = { account_id: accountId, subscriptions: subscriptionPayload.subscriptions || [], entitlements: entitlementPayload.entitlements || [], @@ -160,22 +261,22 @@ async function refreshOpsSubscriptionAudit(options = {}) { revoke_candidates: entitlementPayload.revoke_candidates || [], events: eventPayload.events || [], }; - appState.opsAccountDetail = accountDetail; - appState.opsGovernanceSnapshot = governanceSnapshot; - appState.opsGovernanceExport = governanceExport; + opsState.opsAccountDetail = accountDetail; + opsState.opsGovernanceSnapshot = governanceSnapshot; + opsState.opsGovernanceExport = governanceExport; if ( - appState.opsGovernanceDetail && - !(governanceSnapshot.governance_cases || []).some((item) => item.case_id === appState.opsGovernanceDetail.case_id) + opsState.opsGovernanceDetail && + !(governanceSnapshot.governance_cases || []).some((item) => item.case_id === opsState.opsGovernanceDetail.case_id) ) { - appState.opsGovernanceDetail = null; + opsState.opsGovernanceDetail = null; } } async function refreshOpsAccountWorkspace(options = {}) { const token = options.token; - const accountId = els.opsAccountId?.value.trim() || activeReaderId(); + const accountId = dom.opsAccountId?.value.trim() || resolveActiveReaderId(); if (!accountId) { if (isActiveOpsRefresh(token)) { - appState.opsAccountWorkspace = null; + opsState.opsAccountWorkspace = null; } return; } @@ -183,47 +284,47 @@ async function refreshOpsAccountWorkspace(options = {}) { if (!isActiveOpsRefresh(token)) { return; } - appState.opsAccountWorkspace = payload; + opsState.opsAccountWorkspace = payload; } async function refreshOpsReleaseWorkspace(options = {}) { const token = options.token; const worldId = - (els.opsReleaseWorldId?.value || "").trim() || - appState.selectedOpsWorldId || - appState.opsWorldStatuses?.[0]?.world_id || + (dom.opsReleaseWorldId?.value || "").trim() || + opsState.selectedOpsWorldId || + opsState.opsWorldStatuses?.[0]?.world_id || ""; if (!worldId) { if (isActiveOpsRefresh(token)) { - appState.opsReleaseWorkspace = null; + opsState.opsReleaseWorkspace = null; } return; } - appState.selectedOpsWorldId = worldId; - if (els.opsReleaseWorldId) { - els.opsReleaseWorldId.value = worldId; + opsState.selectedOpsWorldId = worldId; + if (dom.opsReleaseWorldId) { + dom.opsReleaseWorldId.value = worldId; } const payload = await api(`/v1/ops/worlds/${encodeURIComponent(worldId)}/release-workspace?limit=12`); if (!isActiveOpsRefresh(token)) { return; } - appState.opsReleaseWorkspace = payload; + opsState.opsReleaseWorkspace = payload; } function shouldRefreshOpsNavigationModel() { - return appState.opsNavigationPinned || Object.values(currentOpsNavigationContext()).some(Boolean); + return opsState.opsNavigationPinned || Object.values(currentOpsNavigationContext()).some(Boolean); } function shouldRefreshOpsInvestigation() { - if (appState.opsInvestigationPinned) { + if (opsState.opsInvestigationPinned) { return true; } - const worldVersionId = (els.opsInvestigationWorldVersionId?.value || "").trim(); - const caseId = (els.opsInvestigationCaseId?.value || "").trim(); + const worldVersionId = (dom.opsInvestigationWorldVersionId?.value || "").trim(); + const caseId = (dom.opsInvestigationCaseId?.value || "").trim(); return Boolean(worldVersionId || caseId); } function currentOpsAlertFilters() { return { - accountId: (els.opsAlertAccountId?.value || "").trim() || "", - statusFilter: els.opsAlertStatusFilter?.value || "actionable", - severity: els.opsAlertSeverityFilter?.value || "", + accountId: (dom.opsAlertAccountId?.value || "").trim() || "", + statusFilter: dom.opsAlertStatusFilter?.value || "actionable", + severity: dom.opsAlertSeverityFilter?.value || "", }; } async function refreshOpsAlerts(options = {}) { @@ -238,17 +339,17 @@ async function refreshOpsAlerts(options = {}) { if (!isActiveOpsRefresh(token)) { return; } - appState.opsAlertsFeed = feed; + opsState.opsAlertsFeed = feed; const alerts = feed?.alerts || []; if (!alerts.length) { - appState.selectedOpsAlertId = null; - appState.opsAlertDetail = null; + opsState.selectedOpsAlertId = null; + opsState.opsAlertDetail = null; return; } - const selectedId = appState.selectedOpsAlertId && alerts.find((item) => item.alert_id === appState.selectedOpsAlertId) - ? appState.selectedOpsAlertId + const selectedId = opsState.selectedOpsAlertId && alerts.find((item) => item.alert_id === opsState.selectedOpsAlertId) + ? opsState.selectedOpsAlertId : alerts[0].alert_id; - appState.selectedOpsAlertId = selectedId; + opsState.selectedOpsAlertId = selectedId; const detail = await api( `/v1/ops/alerts/${encodeURIComponent(selectedId)}${ filters.accountId ? `?account_id=${encodeURIComponent(filters.accountId)}` : "" @@ -257,11 +358,12 @@ async function refreshOpsAlerts(options = {}) { if (!isActiveOpsRefresh(token)) { return; } - appState.opsAlertDetail = detail; + opsState.opsAlertDetail = detail; } async function loadOpsReviewReleaseScope(token) { - const [queuePayload, worldPayload] = await Promise.all([ + const [reviewHubPayload, queuePayload, worldPayload] = await Promise.all([ + api("/v1/ops/review-hub?limit=80"), api("/v1/ops/review-queue"), api("/v1/library/worlds"), ]); @@ -273,15 +375,87 @@ async function loadOpsReviewReleaseScope(token) { if (!isActiveOpsRefresh(token)) { return; } - appState.opsReviewQueue = queuePayload.reviews || []; - appState.opsWorldStatuses = statuses; - appState.opsWorldHistories = histories; - if (!appState.selectedOpsWorldId && statuses.length) { - appState.selectedOpsWorldId = statuses[0].world_id; + opsState.opsReviewHub = normalizeOpsReviewHubPayload(reviewHubPayload, queuePayload); + opsState.opsReviewQueue = queuePayload.reviews || []; + opsState.opsWorldStatuses = statuses; + opsState.opsWorldHistories = histories; + const reviewItems = opsState.opsReviewHub.items || []; + if (reviewItems.length) { + const selectedId = reviewItems.some((item) => item.review_item_id === opsState.opsSelectedReviewItemId) + ? opsState.opsSelectedReviewItemId + : reviewItems[0].review_item_id; + opsState.opsSelectedReviewItemId = selectedId; + try { + opsState.opsSelectedReviewItemDetail = await api(`/v1/ops/review-items/${encodeURIComponent(selectedId)}`); + try { + opsState.opsSelectedReviewWorkDetail = await api(`/v1/ops/review-items/${encodeURIComponent(selectedId)}/work`); + } catch (_error) { + opsState.opsSelectedReviewWorkDetail = null; + } + const reviewItem = opsState.opsSelectedReviewItemDetail?.review_item || null; + const traceId = + reviewItem?.source_payload?.trace_summary?.trace_id || + (reviewItem?.linked_entities || []).find((entity) => entity.kind === "trace")?.id || + null; + if (traceId) { + opsState.opsSelectedQualityTraceId = traceId; + try { + opsState.opsQualityTraceDetail = await api(`/v1/ops/quality/traces/${encodeURIComponent(traceId)}`); + } catch (_error) { + opsState.opsQualityTraceDetail = null; + } + } else { + opsState.opsSelectedQualityTraceId = null; + opsState.opsQualityTraceDetail = null; + } + } catch (_error) { + opsState.opsSelectedReviewItemDetail = null; + opsState.opsSelectedReviewWorkDetail = null; + opsState.opsSelectedQualityTraceId = null; + opsState.opsQualityTraceDetail = null; + } + } else { + opsState.opsSelectedReviewItemId = null; + opsState.opsSelectedReviewItemDetail = null; + opsState.opsSelectedReviewWorkDetail = null; + opsState.opsSelectedQualityTraceId = null; + opsState.opsQualityTraceDetail = null; + } + if (!opsState.selectedOpsWorldId && statuses.length) { + opsState.selectedOpsWorldId = statuses[0].world_id; + } + if (shellState.opsWorkspace === "release") { + await refreshOpsReleaseWorkspace({ token }); + } else if (isActiveOpsRefresh(token)) { + opsState.opsReleaseWorkspace = null; } - await refreshOpsReleaseWorkspace({ token }); } async function loadOpsRuntimeScope(activeOpsAccountId, token) { + if (!activeOpsAccountId && !shellState.debug) { + if (!isActiveOpsRefresh(token)) { + return; + } + opsState.opsMeters = []; + opsState.opsSchemaLifecycle = null; + opsState.opsDataIntegrity = null; + opsState.opsDeploymentHealthGate = null; + opsState.opsPreflightVerification = null; + opsState.opsDeploymentRunbook = null; + opsState.opsIncidentPlaybook = null; + opsState.opsRuntimeIncidentSnapshot = null; + opsState.opsRuntimeReceipts = []; + opsState.opsQualitySummary = null; + opsState.opsQualityEvents = []; + opsState.opsQualityTraceDetail = null; + opsState.opsProviderRouting = null; + opsState.opsProviderRollout = null; + opsState.opsProviderRuntimeMetrics = null; + opsState.opsStoryBootstrapWorldSummary = []; + opsState.opsStoryBootstrapWorldDetail = null; + opsState.opsCommercializationSummary = null; + opsState.opsProductionSignoffDetail = null; + return; + } const [ meterPayload, schemaLifecycle, @@ -292,9 +466,14 @@ async function loadOpsRuntimeScope(activeOpsAccountId, token) { incidentPlaybook, runtimeIncidentSnapshot, receiptsPayload, + qualitySummary, + qualityEvents, + qualityTraceDetail, providerRouting, providerRollout, providerRuntimeMetrics, + storyBootstrapWorldSummary, + commercializationSummary, ] = await Promise.all([ api("/v1/ops/meters"), api("/v1/ops/schema-lifecycle"), @@ -305,25 +484,63 @@ async function loadOpsRuntimeScope(activeOpsAccountId, token) { api(`/v1/ops/incident-playbook?account_id=${encodeURIComponent(activeOpsAccountId)}`), api(`/v1/ops/runtime-incident-snapshot?account_id=${encodeURIComponent(activeOpsAccountId)}`), api(`/v1/ops/runtime-receipts?account_id=${encodeURIComponent(activeOpsAccountId)}&limit=20`), + api(`/v1/ops/quality/summary?account_id=${encodeURIComponent(activeOpsAccountId)}&limit=20`), + api(`/v1/ops/quality/events?account_id=${encodeURIComponent(activeOpsAccountId)}&limit=20`), + opsState.opsSelectedQualityTraceId + ? api(`/v1/ops/quality/traces/${encodeURIComponent(opsState.opsSelectedQualityTraceId)}`) + : Promise.resolve(null), api("/v1/ops/provider-routing"), api("/v1/ops/provider-rollout"), api(`/v1/ops/provider-runtime-metrics?account_id=${encodeURIComponent(activeOpsAccountId)}&limit=24`), + api("/v1/ops/story-bootstrap-world-summary?limit=12"), + api("/v1/ops/commercialization-summary?limit=25"), ]); if (!isActiveOpsRefresh(token)) { return; } - appState.opsMeters = meterPayload.meters || []; - appState.opsSchemaLifecycle = schemaLifecycle; - appState.opsDataIntegrity = dataIntegrity; - appState.opsDeploymentHealthGate = deploymentHealthGate; - appState.opsPreflightVerification = preflightVerification; - appState.opsDeploymentRunbook = deploymentRunbook; - appState.opsIncidentPlaybook = incidentPlaybook; - appState.opsRuntimeIncidentSnapshot = runtimeIncidentSnapshot; - appState.opsRuntimeReceipts = receiptsPayload.runtime_receipts || []; - appState.opsProviderRouting = providerRouting; - appState.opsProviderRollout = providerRollout; - appState.opsProviderRuntimeMetrics = providerRuntimeMetrics; + opsState.opsMeters = meterPayload.meters || []; + opsState.opsSchemaLifecycle = schemaLifecycle; + opsState.opsDataIntegrity = dataIntegrity; + opsState.opsDeploymentHealthGate = deploymentHealthGate; + opsState.opsPreflightVerification = preflightVerification; + opsState.opsDeploymentRunbook = deploymentRunbook; + opsState.opsIncidentPlaybook = incidentPlaybook; + opsState.opsRuntimeIncidentSnapshot = runtimeIncidentSnapshot; + opsState.opsRuntimeReceipts = receiptsPayload.runtime_receipts || []; + opsState.opsQualitySummary = qualitySummary; + opsState.opsQualityEvents = qualityEvents.events || []; + opsState.opsQualityTraceDetail = qualityTraceDetail; + opsState.opsProviderRouting = providerRouting; + opsState.opsProviderRollout = providerRollout; + opsState.opsProviderRuntimeMetrics = providerRuntimeMetrics; + opsState.opsStoryBootstrapWorldSummary = storyBootstrapWorldSummary.worlds || []; + opsState.opsCommercializationSummary = commercializationSummary; + const summaryWorldIds = new Set((opsState.opsStoryBootstrapWorldSummary || []).map((item) => item.worldId)); + const selectedBootstrapWorldId = summaryWorldIds.has(opsState.selectedOpsWorldId) + ? opsState.selectedOpsWorldId + : (opsState.opsStoryBootstrapWorldSummary?.[0]?.worldId || null); + if (selectedBootstrapWorldId) { + opsState.selectedOpsWorldId = selectedBootstrapWorldId; + try { + opsState.opsStoryBootstrapWorldDetail = await api( + `/v1/ops/story-bootstrap-world-summary/worlds/${encodeURIComponent(selectedBootstrapWorldId)}?limit=12` + ); + } catch (_error) { + opsState.opsStoryBootstrapWorldDetail = null; + } + } else { + opsState.opsStoryBootstrapWorldDetail = null; + } + const currentSignoffId = commercializationSummary?.production_signoff?.signoff_id || ""; + if (currentSignoffId) { + try { + opsState.opsProductionSignoffDetail = await api(`/v1/ops/production-signoff/${encodeURIComponent(currentSignoffId)}`); + } catch (_error) { + opsState.opsProductionSignoffDetail = null; + } + } else { + opsState.opsProductionSignoffDetail = null; + } } async function loadOpsJobsScope(token) { const [ @@ -362,26 +579,33 @@ async function loadOpsJobsScope(token) { if (!isActiveOpsRefresh(token)) { return; } - appState.opsAsyncJobSummary = asyncJobsPayload.summary || null; - appState.opsAsyncJobs = asyncJobsPayload.jobs || []; - appState.opsAsyncJobBootReconcile = bootReconcile; - appState.opsAsyncJobIncidents = incidents; - appState.opsAsyncJobArtifactRetention = artifactRetention; - appState.opsAsyncJobOperatorHistory = operatorHistory; - appState.opsAsyncJobHandoffBundle = handoffBundle; - appState.opsAsyncJobRemoteShipping = remoteShipping; - appState.opsAsyncJobHandoffSla = handoffSla; - appState.opsAsyncJobAdapterValidation = adapterValidation; - appState.opsAsyncJobAdapterHealthProbe = adapterHealthProbe; - appState.opsAsyncJobNotificationReceipts = notificationReceipts; - appState.opsAsyncNotificationRetryQueue = retryQueue; - appState.opsAsyncNotificationDeadLetterQueue = deadLetterQueue; - appState.opsAsyncRetryOutcomeDashboard = retryOutcomeDashboard; - appState.opsAsyncRetryPolicies = retryPolicies; + opsState.opsAsyncJobSummary = asyncJobsPayload.summary || null; + opsState.opsAsyncJobs = asyncJobsPayload.jobs || []; + opsState.opsAsyncJobBootReconcile = bootReconcile; + opsState.opsAsyncJobIncidents = incidents; + opsState.opsAsyncJobArtifactRetention = artifactRetention; + opsState.opsAsyncJobOperatorHistory = operatorHistory; + opsState.opsAsyncJobHandoffBundle = handoffBundle; + opsState.opsAsyncJobRemoteShipping = remoteShipping; + opsState.opsAsyncJobHandoffSla = handoffSla; + opsState.opsAsyncJobAdapterValidation = adapterValidation; + opsState.opsAsyncJobAdapterHealthProbe = adapterHealthProbe; + opsState.opsAsyncJobNotificationReceipts = notificationReceipts; + opsState.opsAsyncNotificationRetryQueue = retryQueue; + opsState.opsAsyncNotificationDeadLetterQueue = deadLetterQueue; + opsState.opsAsyncRetryOutcomeDashboard = retryOutcomeDashboard; + opsState.opsAsyncRetryPolicies = retryPolicies; } async function loadOpsAccountScope(activeOpsAccountId, token) { - if (els.opsAccountId && !els.opsAccountId.value.trim()) { - els.opsAccountId.value = activeOpsAccountId; + if (!activeOpsAccountId) { + if (isActiveOpsRefresh(token)) { + opsState.opsSubscriptionAudit = null; + opsState.opsAccountWorkspace = null; + } + return; + } + if (dom.opsAccountId && !dom.opsAccountId.value.trim()) { + dom.opsAccountId.value = activeOpsAccountId; } await Promise.all([ refreshOpsSubscriptionAudit({ token }), @@ -389,8 +613,8 @@ async function loadOpsAccountScope(activeOpsAccountId, token) { ]); } async function loadOpsAlertsScope(activeOpsAccountId, token) { - if (els.opsAlertAccountId && !els.opsAlertAccountId.value.trim()) { - els.opsAlertAccountId.value = activeOpsAccountId; + if (dom.opsAlertAccountId && !dom.opsAlertAccountId.value.trim()) { + dom.opsAlertAccountId.value = activeOpsAccountId; } await refreshOpsAlerts({ token }); } @@ -415,7 +639,7 @@ async function loadOpsLearnedScope(token) { learnedRerankerPromotion, ] = await Promise.all([ api("/v1/ops/eval-metrics"), - api("/v1/ops/cross-pack-quality"), + refreshOpsCrossPackQuality({ token }), api("/v1/ops/learned-dashboard"), api("/v1/ops/learned-impact"), api("/v1/ops/learned-cadence"), @@ -435,40 +659,40 @@ async function loadOpsLearnedScope(token) { if (!isActiveOpsRefresh(token)) { return; } - appState.opsEvalMetrics = evalMetrics; - appState.opsCrossPackQuality = crossPackQuality; - appState.opsLearnedDashboard = learnedDashboard; - appState.opsLearnedImpact = learnedImpact; - appState.opsLearnedCadence = learnedCadence; - appState.opsLearnedAssistedGate = learnedAssistedGate; - appState.opsLearnedAssistedRerank = learnedAssistedRerank; - appState.opsLearnedReviewQuality = learnedReviewQuality; - appState.opsPreferenceSamples = preferenceSamples.preference_samples || []; - appState.opsRankingSamples = rankingSamples.ranking_samples || []; - appState.opsLearnedEvidence = { + opsState.opsEvalMetrics = evalMetrics; + opsState.opsCrossPackQuality = crossPackQuality; + opsState.opsLearnedDashboard = learnedDashboard; + opsState.opsLearnedImpact = learnedImpact; + opsState.opsLearnedCadence = learnedCadence; + opsState.opsLearnedAssistedGate = learnedAssistedGate; + opsState.opsLearnedAssistedRerank = learnedAssistedRerank; + opsState.opsLearnedReviewQuality = learnedReviewQuality; + opsState.opsPreferenceSamples = preferenceSamples.preference_samples || []; + opsState.opsRankingSamples = rankingSamples.ranking_samples || []; + opsState.opsLearnedEvidence = { evaluator: evaluatorEvidence, reranker: rerankerEvidence, }; - appState.opsLearnedCompare = learnedCompare; - appState.opsLearnedRollout = learnedRollout; - appState.opsLearnedDataOps = learnedDataOps; - appState.opsLearnedPromotion = learnedPromotion; - appState.opsLearnedRerankerPromotion = learnedRerankerPromotion; + opsState.opsLearnedCompare = learnedCompare; + opsState.opsLearnedRollout = learnedRollout; + opsState.opsLearnedDataOps = learnedDataOps; + opsState.opsLearnedPromotion = learnedPromotion; + opsState.opsLearnedRerankerPromotion = learnedRerankerPromotion; const latestTrainingJob = latestAsyncJob("learned_training"); if (latestTrainingJob) { - appState.opsLearnedTrainingResult = { job: latestTrainingJob }; + opsState.opsLearnedTrainingResult = { job: latestTrainingJob }; } } async function loadOpsNavigationScope(activeOpsAccountId, token) { - if (els.opsNavAccountId && !els.opsNavAccountId.value.trim() && appState.opsNavigationPinned) { - els.opsNavAccountId.value = activeOpsAccountId; + if (dom.opsNavAccountId && !dom.opsNavAccountId.value.trim() && opsState.opsNavigationPinned) { + dom.opsNavAccountId.value = activeOpsAccountId; } - if (els.opsNavWorldId && !els.opsNavWorldId.value.trim() && appState.selectedOpsWorldId && appState.opsNavigationPinned) { - els.opsNavWorldId.value = appState.selectedOpsWorldId; + if (dom.opsNavWorldId && !dom.opsNavWorldId.value.trim() && opsState.selectedOpsWorldId && opsState.opsNavigationPinned) { + dom.opsNavWorldId.value = opsState.selectedOpsWorldId; } if (!shouldRefreshOpsNavigationModel()) { if (isActiveOpsRefresh(token)) { - appState.opsNavigationModel = null; + opsState.opsNavigationModel = null; } return; } @@ -476,7 +700,7 @@ async function loadOpsNavigationScope(activeOpsAccountId, token) { await refreshOpsNavigationModel({ token }); } catch (error) { if (isActiveOpsRefresh(token)) { - appState.opsNavigationModel = null; + opsState.opsNavigationModel = null; } } } @@ -484,22 +708,22 @@ async function loadOpsInvestigationScope(activeOpsAccountId, token) { if (!shouldRefreshOpsInvestigation()) { return; } - if (els.opsInvestigationAccountId && !els.opsInvestigationAccountId.value.trim() && appState.opsInvestigationPinned) { - els.opsInvestigationAccountId.value = activeOpsAccountId; + if (dom.opsInvestigationAccountId && !dom.opsInvestigationAccountId.value.trim() && opsState.opsInvestigationPinned) { + dom.opsInvestigationAccountId.value = activeOpsAccountId; } try { - await runOpsInvestigation({ skipRender: true, silent: true, token }); + await OpsActionsRuntime.runOpsInvestigation({ skipRender: true, silent: true, token }); } catch (error) { if (isActiveOpsRefresh(token)) { - appState.opsInvestigationBundle = null; + opsState.opsInvestigationBundle = null; } } } async function refreshOpsSurface(options = {}) { const preserveLastActionImpact = Boolean(options.preserveLastActionImpact); const scopes = normalizeOpsRefreshScopes(options.scopes); - const token = ++appState.opsRefreshRequestId; - const activeOpsAccountId = els.opsAccountId?.value.trim() || activeReaderId(); + const token = ++opsState.opsRefreshRequestId; + const activeOpsAccountId = dom.opsAccountId?.value.trim() || ""; const tasks = []; if (scopes.includes("review_release")) { tasks.push(loadOpsReviewReleaseScope(token)); @@ -531,25 +755,25 @@ async function refreshOpsSurface(options = {}) { } if (scopes.includes("jobs") || scopes.includes("learned")) { const latestTrainingJob = latestAsyncJob("learned_training"); - appState.opsLearnedTrainingResult = latestTrainingJob ? { job: latestTrainingJob } : null; + opsState.opsLearnedTrainingResult = latestTrainingJob ? { job: latestTrainingJob } : null; } if (scopes.includes("learned")) { - appState.opsLearnedDetail = null; + opsState.opsLearnedDetail = null; } if (scopes.includes("review_release")) { - appState.opsReviewCaptureTarget = null; + opsState.opsReviewCaptureTarget = null; } if (!preserveLastActionImpact) { - appState.opsLastActionImpact = null; + opsState.opsLastActionImpact = null; } - renderOpsSurface(); + OpsRenderRuntime.renderOpsSurface(); } async function refreshOpsAccountFlow(options = {}) { await refreshOpsSurface({ ...options, scopes: ["account", "alerts", "navigation"] }); } async function refreshOpsReleaseFlow(options = {}) { const scopes = ["review_release", "navigation"]; - if (appState.opsInvestigationPinned) { + if (opsState.opsInvestigationPinned) { scopes.push("investigation"); } await refreshOpsSurface({ ...options, scopes }); @@ -560,3 +784,20 @@ async function refreshOpsJobsFlow(options = {}) { async function refreshOpsLearnedFlow(options = {}) { await refreshOpsSurface({ ...options, scopes: ["jobs", "learned", "navigation"] }); } + + return { + currentOpsNavigationContext, + OPS_REFRESH_SCOPE_ALL, + normalizeOpsRefreshScopes, + syncOpsNavigationContext, + refreshOpsReleaseWorkspace, + refreshOpsAlerts, + refreshOpsSurface, + refreshOpsAccountFlow, + refreshOpsReleaseFlow, + refreshOpsJobsFlow, + refreshOpsLearnedFlow, + refreshOpsCrossPackQuality, + buildCrossPackQualityUrl + }; +})(); diff --git a/src/narrativeos/web/ops_render_sections.js b/src/narrativeos/web/ops_render_sections.js index 9a8bf5f..8412ebb 100644 --- a/src/narrativeos/web/ops_render_sections.js +++ b/src/narrativeos/web/ops_render_sections.js @@ -1,5 +1,46 @@ // Ops render sections extracted from app.js to keep render/update responsibilities isolated. +var OpsRenderRuntime = (() => { + const dom = OpsDOM; + const { api, clearNode, createListCard, formatTimestamp, formatPercent, parseMaybeJson, localizeDisplayText } = UIShared; + const { gatingStatusLabel } = ReaderAccessors; + const { + reviewStatusLabel, + opsStatusLabel, + opsProviderLabel, + opsTrackLabel, + opsScopeLabel, + opsActionModeLabel, + opsIssueCodeLabel, + opsIssueCodeList, + opsWorldLabel, + opsWorldList, + opsTrackList, + opsPreferredCandidateLabel, + opsBooleanLabel, + opsNumericValue, + opsLatencyValue, + opsCostValue, + opsRolloutSummary, + opsTargetLabel, + opsFieldLine, + opsPairsLine, + summarizeChecklistEvidence, + applySupportPrefill, + applyGovernanceCasePrefill, + openLearnedWorldDetail, + openLearnedIssueDetail, + selectReviewBacklogItem + } = OpsShared; + const { + OPS_REFRESH_SCOPE_ALL, + normalizeOpsRefreshScopes, + syncOpsNavigationContext, + refreshOpsReleaseFlow, + refreshOpsReleaseWorkspace, + refreshOpsSurface + } = OpsRefreshRuntime; + function summarizeReviewTimelineEntry(item) { const note = item.note_payload || {}; const targetVersion = item.target_world_version_id || item.published_world_version_id || item.world_version_id || item.asset_id || "-"; @@ -7,13 +48,51 @@ function summarizeReviewTimelineEntry(item) { const gateSummary = (item.publish_gate_errors || []).join(" / ") || "-"; const riskSummary = (item.risk_summary?.publish_gate_errors || []).join(" / ") || "-"; return ( - `${reviewStatusLabel(item.status)} · ${targetVersion}\n` + - `${formatTimestamp(item.updated_at)} · reviewer ${item.reviewer_id || "-"} · risk ${item.risk_rating || "-"}\n` + - `decision ${item.latest_decision || "-"} · cross-pack ${item.cross_pack_pass_rate !== undefined && item.cross_pack_pass_rate !== null ? Number(item.cross_pack_pass_rate).toFixed(3) : "-"}\n` + - `weakest ${packSummary}\n` + - `gate ${gateSummary}\n` + - `rollback ${item.target_world_version_id || "-"} · previous ${item.previous_world_version_id || "-"}\n` + - `reason ${item.entitlement_reason || note.entitlement_reason || "-"} · risk gate ${riskSummary}` + `${opsFieldLine("状态", reviewStatusLabel(item.status))} · ${targetVersion}\n` + + `${formatTimestamp(item.updated_at)} · ${opsFieldLine("审阅人", item.reviewer_id || "-")} · ${opsFieldLine("风险等级", item.risk_rating || "-")}\n` + + `${opsFieldLine("最近判断", opsStatusLabel(item.latest_decision || "-"))} · ${opsFieldLine("跨包通过率", item.cross_pack_pass_rate !== undefined && item.cross_pack_pass_rate !== null ? Number(item.cross_pack_pass_rate).toFixed(3) : "-")}\n` + + `${opsFieldLine("薄弱世界", (item.top_failing_pack_ids || []).map((worldId) => opsWorldLabel(worldId)).join(" / ") || "-")}\n` + + `${opsFieldLine("发布门禁", gateSummary)}\n` + + `${opsFieldLine("回滚目标", item.target_world_version_id || "-")} · ${opsFieldLine("上一版本", item.previous_world_version_id || "-")}\n` + + `${opsFieldLine("访问原因", item.entitlement_reason || note.entitlement_reason || "-")} · ${opsFieldLine("风险门禁", riskSummary)}` + ); +} + +function summarizeReviewHubItem(item) { + if (item.source_type === "quality_review_case") { + const traceSummary = item.source_payload?.trace_summary || {}; + return ( + `${opsFieldLine("队列", item.queue || "-")} · ${opsFieldLine("状态", opsStatusLabel(item.status || "-"))} · ${opsFieldLine("严重度", item.severity || "-")}\n` + + `${opsFieldLine("负责人", item.owner_id || "-")} · ${opsFieldLine("Surface", traceSummary.source_surface || "-")} · ${opsFieldLine("Trace", traceSummary.trace_id || "-")}\n` + + `${opsFieldLine("账户", item.account_id || "-")} · ${opsFieldLine("世界", opsWorldLabel(item.world_id || "-"))}\n` + + `${opsFieldLine("原因", opsIssueCodeList(traceSummary.reason_codes || []))}\n` + + `${opsFieldLine("建议动作", item.recommended_action || "-")}` + ); + } + return ( + `${opsFieldLine("队列", item.queue || "-")} · ${opsFieldLine("状态", opsStatusLabel(item.status || "-"))} · ${opsFieldLine("严重度", item.severity || "-")}\n` + + `${opsFieldLine("负责人", item.owner_id || "-")} · ${opsFieldLine("审阅人", item.reviewer_id || "-")} · ${opsFieldLine("SLA", item.sla_bucket || "-")}\n` + + `${opsFieldLine("账户", item.account_id || "-")} · ${opsFieldLine("世界", opsWorldLabel(item.world_id || "-"))}\n` + + `${item.summary || "-"}\n` + + `${opsFieldLine("建议动作", item.recommended_action || "-")}` + ); +} + +function summarizeReviewHubSummary(summary) { + return opsSections( + opsSection("总体", [ + opsFieldLine("总数", summary.total_count ?? 0), + opsFieldLine("待处理", summary.actionable_count ?? 0), + opsFieldLine("未分派", summary.unassigned_count ?? 0), + opsFieldLine("阻塞", summary.blocked_count ?? 0), + opsFieldLine("即将超时", summary.due_soon_count ?? 0), + opsFieldLine("已超时", summary.overdue_count ?? 0), + ]), + opsSection("队列分布", [ + opsPairsLine("队列", summary.queue_counts || {}), + opsPairsLine("状态", summary.status_counts || {}, opsStatusLabel), + opsPairsLine("严重度", summary.severity_counts || {}), + ]) ); } @@ -26,9 +105,9 @@ function formatSignedDelta(value) { function summarizeRollbackEntry(item) { return ( `${reviewStatusLabel(item.status)} · ${formatTimestamp(item.updated_at)}\n` + - `target ${item.rollback_target_world_version_id || "-"} · previous ${item.rollback_previous_world_version_id || "-"}\n` + - `reviewer ${item.reviewer_id || "-"} · reason ${item.rollback_reason || "-"}\n` + - `gate ${((item.rollback_gate_errors || []).join(" / ")) || "-"}` + `${opsFieldLine("回滚目标", item.rollback_target_world_version_id || "-")} · ${opsFieldLine("上一版本", item.rollback_previous_world_version_id || "-")}\n` + + `${opsFieldLine("审阅人", item.reviewer_id || "-")} · ${opsFieldLine("原因", item.rollback_reason || "-")}\n` + + `${opsFieldLine("门禁", ((item.rollback_gate_errors || []).join(" / ")) || "-")}` ); } @@ -36,65 +115,200 @@ function summarizeQualityTrendEntry(item) { const delta = item.delta_vs_previous || {}; return ( `${item.world_version_id}\n` + - `${item.status} · decision ${item.latest_decision || "-"} · updated ${formatTimestamp(item.updated_at)}\n` + - `pass ${formatPercent(item.pass_rate)} (${formatSignedDelta(delta.pass_rate)}) · rewrite ${formatPercent(item.rewrite_rate)} (${formatSignedDelta(delta.rewrite_rate)})\n` + - `block ${formatPercent(item.block_rate)} (${formatSignedDelta(delta.block_rate)}) · cross-pack ${Number(item.cross_pack_pass_rate || 0).toFixed(3)} (${formatSignedDelta(delta.cross_pack_pass_rate)})\n` + - `regression ${item.regression_detected ? "yes" : "no"} · gate ${(item.publish_gate_errors || []).join(" / ") || "-"}\n` + - `weakest ${(item.top_failing_pack_ids || []).join(" / ") || "-"}` + `${opsFieldLine("状态", opsStatusLabel(item.status))} · ${opsFieldLine("最近判断", opsStatusLabel(item.latest_decision || "-"))} · ${opsFieldLine("更新时间", formatTimestamp(item.updated_at))}\n` + + `${opsFieldLine("通过率", `${formatPercent(item.pass_rate)} (${formatSignedDelta(delta.pass_rate)})`)} · ${opsFieldLine("重写率", `${formatPercent(item.rewrite_rate)} (${formatSignedDelta(delta.rewrite_rate)})`)}\n` + + `${opsFieldLine("阻塞率", `${formatPercent(item.block_rate)} (${formatSignedDelta(delta.block_rate)})`)} · ${opsFieldLine("跨包通过率", `${Number(item.cross_pack_pass_rate || 0).toFixed(3)} (${formatSignedDelta(delta.cross_pack_pass_rate)})`)}\n` + + `${opsFieldLine("是否回退", opsBooleanLabel(item.regression_detected))} · ${opsFieldLine("门禁", (item.publish_gate_errors || []).join(" / ") || "-")}\n` + + `${opsFieldLine("薄弱世界", (item.top_failing_pack_ids || []).map((worldId) => opsWorldLabel(worldId)).join(" / ") || "-")}` ); } function summarizeReleaseBlocker(item) { return ( `${item.label || item.key}\n` + - `${item.reason || "-"} · owner ${item.owner || "-"} · severity ${item.severity || "-"}\n` + - `next ${item.next_action || "-"} · evidence ${summarizeChecklistEvidence(item.evidence)}` + `${opsFieldLine("原因", item.reason || "-")} · ${opsFieldLine("负责人", item.owner || "-")} · ${opsFieldLine("严重度", item.severity || "-")}\n` + + `${opsFieldLine("下一步", item.next_action || "-")} · ${opsFieldLine("证据", summarizeChecklistEvidence(item.evidence))}` + ); +} + +function opsSection(title, lines = []) { + const visibleLines = (lines || []).filter((item) => String(item || "").trim()); + if (!visibleLines.length) return ""; + return `${title}:\n${visibleLines.join("\n")}`; +} + +function opsSections(...sections) { + return sections.filter((item) => String(item || "").trim()).join("\n\n"); +} + +function opsList(items, formatter = (item) => item, empty = "-") { + if (!Array.isArray(items) || !items.length) return empty; + return items.map((item, index) => formatter(item, index)).join("\n"); +} + +function opsParagraphList(items, formatter = (item) => item, empty = "-") { + if (!Array.isArray(items) || !items.length) return empty; + return items.map((item, index) => formatter(item, index)).join("\n\n"); +} + +function renderLocalizedCardHtml(title, score, body, footer = "") { + return ` +
+

${localizeDisplayText(title)}

+ ${localizeDisplayText(score || "")} +
+

${localizeDisplayText(body || "")}

+ ${footer} + `; +} + +function opsStatusPairList(pairs) { + return opsPairsLine("状态分布", pairs || {}, opsStatusLabel); +} + +function opsProviderPairList(label, pairs) { + return opsPairsLine(label, pairs || {}, opsProviderLabel); +} + +function opsStageMetricParagraph(track, item) { + return opsSections( + opsSection(`${opsTrackLabel(track)} · ${opsStatusLabel(item.rollout_status || "-")}`, [ + opsFieldLine("回执数", item.receipt_count ?? 0), + opsFieldLine("事故率", opsNumericValue(item.incident_rate, 3)), + opsFieldLine("回退率", opsNumericValue(item.fallback_rate, 3)), + opsFieldLine("后端错误率", opsNumericValue(item.backend_error_rate, 3)), + opsFieldLine("总成本", opsCostValue(item.total_estimated_cost, 3)), + opsFieldLine("平均成本", opsCostValue(item.avg_estimated_cost, 3)), + opsFieldLine("运行时延迟", opsLatencyValue(item.runtime_latency?.avg_latency_ms)), + opsFieldLine("运行时 P95", opsLatencyValue(item.runtime_latency?.p95_latency_ms)), + opsFieldLine("轨道延迟", opsLatencyValue(item.track_latency?.avg_latency_ms)), + opsFieldLine("命中分桶次数", item.canary_match_count ?? 0), + ]) + ); +} + +function opsProviderMetricParagraph(item) { + return opsSections( + opsSection(opsProviderLabel(item.provider || "-"), [ + opsFieldLine("回执数", item.receipt_count ?? 0), + opsFieldLine("事故数", item.incident_count ?? 0), + opsFieldLine("候选选中", item.selected_as_candidate_count ?? 0), + opsFieldLine("渲染选中", item.selected_as_renderer_count ?? 0), + opsFieldLine("回退率", opsNumericValue(item.fallback_rate, 3)), + opsFieldLine("预算拦截率", opsNumericValue(item.budget_block_rate, 3)), + opsFieldLine("后端错误率", opsNumericValue(item.backend_error_rate, 3)), + opsFieldLine("缓存命中率", opsNumericValue(item.cache_hit_rate, 3)), + opsFieldLine("运行时延迟", opsLatencyValue(item.avg_runtime_latency_ms)), + opsFieldLine("运行时 P95", opsLatencyValue(item.p95_runtime_latency_ms)), + opsFieldLine("候选延迟", opsLatencyValue(item.avg_candidate_latency_ms)), + opsFieldLine("渲染延迟", opsLatencyValue(item.avg_renderer_latency_ms)), + opsFieldLine("总成本", opsCostValue(item.total_estimated_cost, 3)), + opsFieldLine("平均成本", opsCostValue(item.avg_estimated_cost, 3)), + opsFieldLine("候选请求成本", opsCostValue(item.candidate_estimated_request_cost, 4)), + opsFieldLine("渲染请求成本", opsCostValue(item.renderer_estimated_request_cost, 4)), + opsFieldLine("平均输出字符", opsNumericValue(item.avg_output_chars, 1)), + ]) + ); +} + +function opsCostTrendParagraph(item) { + return opsSections( + opsSection(item.bucket || "-", [ + opsFieldLine("总成本", opsCostValue(item.total_estimated_cost, 3)), + opsFieldLine("回执数", item.receipt_count ?? 0), + opsFieldLine("事故数", item.incident_count ?? 0), + ]) + ); +} + +function opsLatencyTrendParagraph(item) { + return opsSections( + opsSection(item.bucket || "-", [ + opsFieldLine("运行时延迟", opsLatencyValue(item.runtime?.avg_latency_ms)), + opsFieldLine("候选延迟", opsLatencyValue(item.candidate?.avg_latency_ms)), + opsFieldLine("渲染延迟", opsLatencyValue(item.renderer?.avg_latency_ms)), + ]) + ); +} + +function opsPromotionBody(promotion, metricLabel, metricValue) { + return opsSections( + opsSection("总体判断", [ + opsFieldLine("轨道", opsTrackLabel(promotion.track || "-")), + opsFieldLine("范围", opsScopeLabel(promotion.scope || "-")), + opsFieldLine("建议结论", opsStatusLabel(promotion.recommendation_status || promotion.status || "-")), + opsFieldLine("审批状态", opsStatusLabel(promotion.approval_status || "pending")), + opsFieldLine("需要重新确认", opsBooleanLabel(promotion.reconfirm_required)), + opsFieldLine("下一步", promotion.recommended_action || "-"), + ]), + opsSection("最近审批", [ + opsFieldLine("状态", opsStatusLabel(promotion.latest_approval_record?.status || "-")), + opsFieldLine("审阅人", promotion.latest_approval_record?.reviewer_id || "-"), + opsFieldLine("更新时间", promotion.latest_approval_record?.updated_at || "-"), + opsFieldLine("原因", promotion.latest_approval_record?.reason || "-"), + ]), + opsSection("阻塞与提示", [ + opsFieldLine("阻塞项", (promotion.blockers || []).join(" / ") || "-"), + opsFieldLine("提示项", (promotion.advisories || []).join(" / ") || "-"), + ]), + opsSection("证据包", [ + opsFieldLine(metricLabel, metricValue), + opsFieldLine("训练/验证/测试", `${promotion.evidence?.train_count ?? 0} / ${promotion.evidence?.val_count ?? 0} / ${promotion.evidence?.test_count ?? 0}`), + opsFieldLine("偏好候选", opsPreferredCandidateLabel(promotion.evidence?.preferred_shadow_candidate)), + opsFieldLine("审阅待补样", promotion.evidence?.review_backlog_count ?? 0), + opsFieldLine("配对覆盖待补样", promotion.evidence?.pair_backlog_count ?? 0), + opsFieldLine("世界分歧数", promotion.evidence?.disagreement_world_count ?? 0), + opsFieldLine("问题分歧数", promotion.evidence?.disagreement_issue_count ?? 0), + ]), + opsSection("检查清单", [ + opsList(promotion.checklist || [], (item) => `${item.ok ? "已通过" : "未通过"} · ${item.key} · ${item.reason || "-"}`), + ]) ); } function renderOpsNavigationSection() { - clearNode(els.opsNavigationSummary); - clearNode(els.opsNavigationTargets); - clearNode(els.opsNavigationActions); - if (!appState.opsNavigationModel) { - clearNode(els.opsNavigationSummary, "这里会显示统一 context、升级状态与推荐路径。"); - clearNode(els.opsNavigationTargets, "这里会显示 linked targets 与导航入口。"); - clearNode(els.opsNavigationActions, "这里会显示跨面板 follow-up actions。"); + clearNode(dom.opsNavigationSummary); + clearNode(dom.opsNavigationTargets); + clearNode(dom.opsNavigationActions); + if (!opsState.opsNavigationModel) { + clearNode(dom.opsNavigationSummary, "这里会显示统一上下文、升级状态与推荐路径。"); + clearNode(dom.opsNavigationTargets, "这里会显示关联对象与导航入口。"); + clearNode(dom.opsNavigationActions, "这里会显示跨面板后续动作。"); } else { - const model = appState.opsNavigationModel; + const model = opsState.opsNavigationModel; const context = model.active_context || {}; const escalation = model.escalation_summary || {}; const warnings = model.context_warnings || []; const staleRefs = Object.values(model.linked_context?.stale_refs || {}); - els.opsNavigationSummary.appendChild( + dom.opsNavigationSummary.appendChild( createListCard({ - title: "Ops Navigation Model", - score: escalation.status || "-", + title: "运营导航模型", + score: opsStatusLabel(escalation.status || "-"), body: - `account ${context.account_id || "-"} · world ${context.world_id || "-"} · world_version ${context.world_version_id || "-"}\n` + - `case ${context.case_id || "-"} · alert ${context.alert_id || "-"}\n` + - `recommended ${escalation.recommended_target || "-"}\n` + - `reason ${escalation.recommended_reason || "-"}\n` + - `path ${(escalation.escalation_path || []).join(" -> ") || "-"}\n` + - `resolution ${(model.context_resolution || []).join(" / ") || "-"}${ + `${opsFieldLine("账户", context.account_id || "-")} · ${opsFieldLine("世界", opsWorldLabel(context.world_id || "-"))} · ${opsFieldLine("世界版本", context.world_version_id || "-")}\n` + + `${opsFieldLine("个案", context.case_id || "-")} · ${opsFieldLine("告警", context.alert_id || "-")}\n` + + `${opsFieldLine("推荐目标", escalation.recommended_target || "-")}\n` + + `${opsFieldLine("推荐原因", escalation.recommended_reason || "-")}\n` + + `${opsFieldLine("升级路径", (escalation.escalation_path || []).join(" → ") || "-")}\n` + + `${opsFieldLine("上下文处理", (model.context_resolution || []).join(" / ") || "-")}${ warnings.length - ? `\nwarning ${warnings.join(" / ")}` + ? `\n${opsFieldLine("告警", warnings.join(" / "))}` : "" }${ staleRefs.length - ? `\nstale refs ${staleRefs.map((item) => `${item.ref_id} · ${item.status}`).join(" / ")}` + ? `\n${opsFieldLine("失效引用", staleRefs.map((item) => `${item.ref_id} · ${opsStatusLabel(item.status)}`).join(" / "))}` : "" }` }) ); if (!(model.navigation_targets || []).length) { - clearNode(els.opsNavigationTargets, "这里会显示 linked targets 与导航入口。"); + clearNode(dom.opsNavigationTargets, "这里会显示关联对象与导航入口。"); } else { const targetCard = createListCard({ - title: "Linked Targets", - score: `${(model.navigation_targets || []).length} targets`, - body: (model.navigation_targets || []).map((item) => `${item.label} · ${item.kind}${item.active ? " · active" : ""}`).join("\n"), + title: "关联目标", + score: `${(model.navigation_targets || []).length} 个目标`, + body: (model.navigation_targets || []).map((item) => `${item.label} · ${item.kind}${item.active ? " · 当前目标" : ""}`).join("\n"), }); const actions = document.createElement("div"); actions.className = "composer-actions"; @@ -104,22 +318,22 @@ function renderOpsNavigationSection() { button.textContent = item.label; button.addEventListener("click", async () => { try { - await runOpsNavigationTarget(item); + await OpsActionsRuntime.runOpsNavigationTarget(item); } catch (error) { alert(`打开 navigation target 失败:${error.message}`); } }); actions.appendChild(button); }); - els.opsNavigationTargets.appendChild(targetCard); - els.opsNavigationTargets.appendChild(actions); + dom.opsNavigationTargets.appendChild(targetCard); + dom.opsNavigationTargets.appendChild(actions); } if (!(model.follow_up_actions || []).length) { - clearNode(els.opsNavigationActions, "这里会显示跨面板 follow-up actions。"); + clearNode(dom.opsNavigationActions, "这里会显示跨面板后续动作。"); } else { const followUpCard = createListCard({ - title: "Follow-up Actions", - score: `${(model.follow_up_actions || []).length} actions`, + title: "后续动作", + score: `${(model.follow_up_actions || []).length} 个动作`, body: (model.follow_up_actions || []).map((item) => `${item.label} · ${item.source_surface || "-"}\n${item.reason || "-"}`).join("\n\n"), }); const actions = document.createElement("div"); @@ -130,82 +344,243 @@ function renderOpsNavigationSection() { button.textContent = item.label; button.addEventListener("click", async () => { try { - await runOpsNavigationFollowUpAction(item); + await OpsActionsRuntime.runOpsNavigationFollowUpAction(item); } catch (error) { alert(`执行 follow-up action 失败:${error.message}`); } }); actions.appendChild(button); }); - els.opsNavigationActions.appendChild(followUpCard); - els.opsNavigationActions.appendChild(actions); + dom.opsNavigationActions.appendChild(followUpCard); + dom.opsNavigationActions.appendChild(actions); } } } function renderOpsReviewReleaseSection() { - clearNode(els.opsReviewQueue); - if (!appState.opsReviewQueue.length) { - clearNode(els.opsReviewQueue, "暂时没有待审核版本。"); + clearNode(dom.opsReviewQueue); + const reviewHub = opsState.opsReviewHub || {}; + const reviewItems = reviewHub.items || []; + const triage = reviewHub.triage || {}; + if (!reviewItems.length) { + clearNode(dom.opsReviewQueue, "暂时没有统一审阅项。"); } else { - appState.opsReviewQueue.forEach((item) => { + dom.opsReviewQueue.appendChild( + createListCard({ + title: "统一审阅台", + score: `${reviewItems.length} items`, + body: summarizeReviewHubSummary(reviewHub.summary || {}), + }) + ); + reviewItems.forEach((item) => { const notePayload = parseMaybeJson(item.notes); const card = document.createElement("article"); card.className = "list-card"; - card.innerHTML = ` -
-

${item.asset_id}

- ${item.status} -
-

${typeof notePayload === "object" ? `latest ${notePayload.latest_decision || "-"}\ncross-pack ${Number(notePayload.cross_pack_pass_rate || 0).toFixed(3)}\n${(notePayload.top_failing_packs || []).map((pack) => pack.world_id).join(" / ")}${(item.publish_gate_errors || []).length ? `\n\npublish gate:\n${item.publish_gate_errors.join("\n")}` : ""}` : (item.notes || "待审核")}

-
- -
- `; - card.querySelector(".review-publish").addEventListener("click", async () => { - await api(`/v1/ops/world-versions/${item.asset_id}/publish`, { - method: "POST", - body: JSON.stringify({ reviewer_id: "web_ops" }), - }); - await refreshOpsReleaseFlow(); + if (item.review_item_id === opsState.opsSelectedReviewItemId) { + card.classList.add("is-active"); + } + const body = summarizeReviewHubItem(item); + card.innerHTML = renderLocalizedCardHtml( + item.headline || item.review_item_id, + `${item.queue || "-"} · ${item.status || "-"}`, + body, + `
+ + + + +
` + ); + card.querySelector(".review-open").addEventListener("click", async () => { + await OpsActionsRuntime.loadSelectedOpsReviewItem(item.review_item_id); + OpsRenderRuntime.renderOpsSurface(); + }); + card.querySelector(".review-assign").addEventListener("click", async () => { + await OpsActionsRuntime.runOpsReviewHubAction(item, "assign_to_me"); + }); + card.querySelector(".review-in-review").addEventListener("click", async () => { + await OpsActionsRuntime.runOpsReviewHubAction(item, "mark_in_review"); + }); + card.querySelector(".review-primary").addEventListener("click", async () => { + await OpsActionsRuntime.runOpsReviewHubAction(item, item.queue === "content_release" ? "approve" : "resolve"); }); - els.opsReviewQueue.appendChild(card); + dom.opsReviewQueue.appendChild(card); }); } - clearNode(els.opsWorldStatus); - clearNode(els.opsReleaseWorkspaceSummary); - clearNode(els.opsReleaseWorkspaceActions); - clearNode(els.opsReleaseWorkspaceTimeline); - clearNode(els.opsReleaseWorkspaceDetails); - if (!appState.opsWorldStatuses.length) { - clearNode(els.opsWorldStatus, "选择或刷新后,这里会显示 world version 状态。"); - clearNode(els.opsReleaseWorkspaceSummary, "这里会显示当前 world 的 release summary。"); - clearNode(els.opsReleaseWorkspaceActions, "这里会显示当前 world 的 quick actions。"); - clearNode(els.opsReleaseWorkspaceTimeline, "这里会显示当前 world 的 operator timeline。"); - clearNode(els.opsReleaseWorkspaceDetails, "这里会显示 publish blockers、version matrix 与 rollback workspace。"); + clearNode(dom.opsWorldStatus); + clearNode(dom.opsReleaseWorkspaceSummary); + clearNode(dom.opsReleaseWorkspaceActions); + clearNode(dom.opsReleaseWorkspaceTimeline); + clearNode(dom.opsReleaseWorkspaceDetails); + if (!opsState.opsWorldStatuses.length) { + clearNode(dom.opsWorldStatus, "选择或刷新后,这里会显示当前审阅项详情。"); + clearNode(dom.opsReleaseWorkspaceSummary, "这里会显示当前世界的发布摘要。"); + clearNode(dom.opsReleaseWorkspaceActions, "这里会显示当前世界的快捷动作。"); + clearNode(dom.opsReleaseWorkspaceTimeline, "这里会显示当前世界的运营时间线。"); + clearNode(dom.opsReleaseWorkspaceDetails, "这里会显示发布阻塞项、版本矩阵与回滚工作台。"); } else { - appState.opsWorldStatuses.forEach((status) => { + const selectedReviewItem = opsState.opsSelectedReviewItemDetail?.review_item || null; + if (selectedReviewItem) { + const selectedWork = opsState.opsSelectedReviewWorkDetail?.work || null; + const selectedQualityPayload = + selectedReviewItem.source_type === "quality_review_case" + ? (opsState.opsSelectedReviewItemDetail?.review_item?.source_payload || {}) + : {}; + const selectedQualityCase = selectedQualityPayload.review_case || {}; + const selectedQualityEvent = selectedQualityPayload.quality_event || {}; + const selectedQualityScore = selectedQualityPayload.content_quality_score || {}; + const selectedQualityTrace = selectedQualityPayload.trace_summary || {}; + const selectedQualityTraceDetail = opsState.opsQualityTraceDetail || null; + const selectedChapter = selectedWork?.chapters?.length ? selectedWork.chapters[selectedWork.chapters.length - 1] : null; + const workDiagnostics = selectedWork?.diagnostics_summary || selectedWork?.diagnostics_summary_json || {}; + const workEvaluation = workDiagnostics.evaluation_summary || {}; + const selectedChapterDiagnostics = selectedChapter?.latest_diagnostic_summary || {}; + const selectedChapterIssueCodes = + selectedChapterDiagnostics.issue_codes || + (selectedChapter?.diagnostic_summary_json?.issues || []) + .map((item) => item?.issue_code) + .filter(Boolean); + const detailCard = createListCard({ + title: selectedReviewItem.headline || selectedReviewItem.review_item_id, + score: `${selectedReviewItem.queue || "-"} · ${opsStatusLabel(selectedReviewItem.status || "-")}`, + body: opsSections( + opsSection("当前审阅项", [ + opsFieldLine("来源", `${selectedReviewItem.source_type || "-"} / ${selectedReviewItem.source_id || "-"}`), + opsFieldLine("负责人", selectedReviewItem.owner_id || "-"), + opsFieldLine("审阅人", selectedReviewItem.reviewer_id || "-"), + opsFieldLine("严重度", selectedReviewItem.severity || "-"), + opsFieldLine("SLA", selectedReviewItem.sla_bucket || "-"), + opsFieldLine("建议动作", selectedReviewItem.recommended_action || "-"), + ]), + opsSection("上下文", [ + opsFieldLine("账户", selectedReviewItem.account_id || "-"), + opsFieldLine("世界", opsWorldLabel(selectedReviewItem.world_id || "-")), + opsFieldLine("世界版本", selectedReviewItem.world_version_id || "-"), + opsFieldLine("关联对象", (selectedReviewItem.linked_entities || []).map((entity) => `${entity.kind}:${entity.id}`).join(" / ") || "-"), + ]), + opsSection("摘要", [ + selectedReviewItem.summary || "-", + ]), + selectedReviewItem.source_type === "quality_review_case" ? opsSection("质量案例", [ + opsFieldLine("Case 状态", opsStatusLabel(selectedQualityCase.status || "-")), + opsFieldLine("Owner", selectedQualityCase.owner_id || "-"), + opsFieldLine("Surface", selectedQualityEvent.source_surface || selectedQualityCase.source_surface || "-"), + opsFieldLine("Trace", selectedQualityTrace.trace_id || selectedQualityEvent.trace_id || "-"), + opsFieldLine("原因", opsIssueCodeList(selectedQualityCase.reason_codes || selectedQualityScore.reason_codes || [])), + opsFieldLine("总分", selectedQualityScore.overall_score !== undefined && selectedQualityScore.overall_score !== null ? Number(selectedQualityScore.overall_score).toFixed(2) : "-"), + opsFieldLine("Veto", selectedQualityScore.veto === undefined ? "-" : opsBooleanLabel(selectedQualityScore.veto)), + opsFieldLine("证据", (selectedQualityCase.evidence_refs || []).map((item) => `${item.kind || "evidence"}:${item.ref_id || "-"}`).join(" / ") || "-"), + opsFieldLine("来源引用", Object.entries(selectedQualityCase.source_ref || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"), + ]) : "", + selectedReviewItem.source_type === "quality_review_case" && selectedQualityTraceDetail ? opsSection("Trace Drilldown", [ + opsFieldLine("Trace", selectedQualityTraceDetail.trace_id || "-"), + opsFieldLine("事件状态", opsStatusLabel(selectedQualityTraceDetail.event?.status || "-")), + opsFieldLine("入口", selectedQualityTraceDetail.event?.source_surface || "-"), + opsFieldLine("账户", selectedQualityTraceDetail.linked_context?.account_id || "-"), + opsFieldLine("世界版本", selectedQualityTraceDetail.linked_context?.world_version_id || "-"), + opsFieldLine("反馈数", selectedQualityTraceDetail.feedback_summary?.feedback_item_count ?? 0), + opsFieldLine("重试信号", selectedQualityTraceDetail.feedback_summary?.retry_signal_count ?? 0), + `反馈时间线:\n${(selectedQualityTraceDetail.feedback_items || []).map((item) => `${item.feedback_type || "-"} · ${item.signal || "-"} · ${formatTimestamp(item.created_at)}\n${Object.entries(item.payload || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}`).join("\n\n") || "-"}`, + ]) : "", + selectedWork ? opsSection("作品稿", [ + opsFieldLine("作品编号", selectedWork.work_id || "-"), + opsFieldLine("作品状态", selectedWork.status || "-"), + opsFieldLine("章节进度", `${selectedWork.chapter_count || 0}/${selectedWork.target_chapter_count || 0}`), + opsFieldLine("最新修订", selectedWork.latest_revision?.revision_type || "-"), + opsFieldLine("最新诊断", workDiagnostics.latest_decision || "-"), + ]) : "", + selectedWork ? opsSection("章节列表", [ + (selectedWork.chapters || []) + .map((chapter) => `第 ${chapter.chapter_index} 章 · ${chapter.chapter_title}\n${chapter.status || "-"} / ${chapter.source_type || "-"}`) + .join("\n\n") || "-", + ]) : "", + selectedWork ? opsSection("正文预览", [ + selectedChapter + ? `第 ${selectedChapter.chapter_index} 章 · ${selectedChapter.chapter_title}\n${selectedChapter.body || "-"}` + : "-", + ]) : "", + selectedWork ? opsSection("诊断证据", [ + `latest ${workDiagnostics.latest_decision || "-"}\n` + + `pass ${formatPercent(workEvaluation.pass_rate)}\n` + + `rewrite ${formatPercent(workEvaluation.rewrite_rate)}\n` + + `block ${formatPercent(workEvaluation.block_rate)}\n` + + `chapter ${(selectedChapterIssueCodes || []).join(" / ") || "-"}`, + ]) : "", + opsSection("源负载", [ + localizeDisplayText(JSON.stringify((opsState.opsSelectedReviewItemDetail?.review_item?.source_payload || opsState.opsSelectedReviewItemDetail?.source_payload || {}), null, 2)), + ]) + ), + }); + const actions = document.createElement("div"); + actions.className = "composer-actions"; + (selectedReviewItem.allowed_actions || []).slice(0, 8).forEach((actionId) => { + const button = document.createElement("button"); + button.className = ["approve", "resolve"].includes(actionId) ? "primary-action" : "ghost-action"; + button.textContent = { + assign_to_me: "分给我", + mark_triaged: "标记已分诊", + mark_in_review: "进入审阅", + approve: "批准", + needs_changes: "要求修改", + block: "阻塞", + resolve: "解决", + dismiss: "驳回", + open_release_workspace: "打开发布台", + open_account_workspace: "打开账户台", + open_governance_case: "打开治理个案", + open_investigation: "打开排查", + escalate_to_governance: "升级到治理", + }[actionId] || actionId; + button.addEventListener("click", async () => { + await OpsActionsRuntime.runOpsReviewHubAction(selectedReviewItem, actionId); + }); + actions.appendChild(button); + }); + dom.opsWorldStatus.appendChild(detailCard); + dom.opsWorldStatus.appendChild(actions); + const triageCard = createListCard({ + title: "跨域分诊", + score: `${(triage.unassigned || []).length} unassigned`, + body: opsSections( + opsSection("未分派", [opsList(triage.unassigned || [], (item) => `${item.headline} · ${item.queue} · ${item.severity}`)]), + opsSection("阻塞", [opsList(triage.blocked || [], (item) => `${item.headline} · ${item.queue} · ${item.status}`)]), + opsSection("即将超时", [opsList(triage.due_soon || [], (item) => `${item.headline} · ${item.sla_bucket} · due ${item.due_at || "-"}`)]), + ), + }); + dom.opsWorldStatus.appendChild(triageCard); + } else { + clearNode(dom.opsWorldStatus, "选择一个统一审阅项后,这里会显示详情、动作和跨域分诊摘要。"); + } + opsState.opsWorldStatuses.forEach((status) => { const card = document.createElement("article"); card.className = "list-card"; - if (status.world_id === appState.selectedOpsWorldId) { + if (status.world_id === opsState.selectedOpsWorldId) { card.classList.add("is-active"); } const rollbackTarget = status.versions.find((item) => item.world_version_id !== status.published_version); const checklistSummary = status.publish_checklist_summary || {}; const checklistDrilldown = (status.publish_checklist || []) - .map((item) => `${item.ok ? "✓" : "×"} ${item.label} · ${item.reason || "-"}\nowner ${item.owner || "-"} · severity ${item.severity || "-"} · next ${item.next_action || "-"}\nevidence ${summarizeChecklistEvidence(item.evidence)}`) + .map((item) => `${item.ok ? "✓" : "×"} ${item.label} · ${item.reason || "-"}\n负责人 ${item.owner || "-"} · 严重度 ${item.severity || "-"} · 下一步 ${item.next_action || "-"}\n证据 ${summarizeChecklistEvidence(item.evidence)}`) .join("\n\n") || "暂无 checklist"; const reviewDrilldown = (status.recent_reviews_drilldown || []) .map((item) => summarizeReviewTimelineEntry(item)) .join("\n\n") || "暂无 recent reviews"; - card.innerHTML = ` -
-

${status.world_id}

- ${status.published_version || "未发布"} -
-

${status.versions.map((item) => `${item.world_version_id} · ${item.status}`).join("\n")}\n\npublish checklist summary:\nready ${checklistSummary.publish_ready ? "yes" : "no"} · blocked ${checklistSummary.blocked_count ?? 0}/${checklistSummary.total ?? 0}\nowners ${(checklistSummary.owners || []).join(" / ") || "-"}\nnext ${(checklistSummary.next_actions || []).join(" / ") || "-"}\n\npublish checklist drill-down:\n${checklistDrilldown}\n\nrisk summary:\n可发布 ${status.risk_summary?.publish_ready ? "yes" : "no"}\ngate ${((status.risk_summary?.publish_gate_errors) || []).join(" / ") || "-"}\n最近回滚 ${status.risk_summary?.latest_rollback_target || "-"} · ${status.risk_summary?.latest_rollback_reason || "-"}\nentitlement alerts ${((status.risk_summary?.entitlement_alerts) || []).map((item) => `${item.event_name}:${item.reason || "-"}`).join(" / ") || "-"}\n\nlearned shadow:\nstatus ${status.learned_shadow_summary?.status || "-"} · agreement ${status.learned_shadow_summary?.agreement_rate !== null && status.learned_shadow_summary?.agreement_rate !== undefined ? Number(status.learned_shadow_summary.agreement_rate).toFixed(3) : "-"}\nissues ${((status.learned_shadow_summary?.top_mismatch_issue_codes) || []).slice(0, 3).map((item) => item.issue_code || item.key).join(" / ") || "-"}\nnext ${status.learned_shadow_summary?.recommended_next_action || "-"}\n\nreranker shadow:\nstatus ${status.learned_reranker_shadow_summary?.status || "-"} · accuracy ${status.learned_reranker_shadow_summary?.per_world_accuracy?.[status.world_id] !== undefined ? Number(status.learned_reranker_shadow_summary.per_world_accuracy[status.world_id]).toFixed(3) : "-"}\nnext ${status.learned_reranker_shadow_summary?.recommended_next_action || "-"}\n\nrecent review drill-down:\n${reviewDrilldown}${(status.recent_entitlement_events || []).length ? `\n\nrecent entitlement events:\n${status.recent_entitlement_events.slice(0, 5).map((item) => `${item.event_name} · ${item.reason || "-"} · ${formatTimestamp(item.occurred_at)}`).join("\n")}` : ""}

- ${rollbackTarget ? `
` : ""} - `; + const worldStatusBody = + `${status.versions.map((item) => `${item.world_version_id} · ${item.status}`).join("\n")}\n\n` + + `发布清单摘要:\n可发布 ${checklistSummary.publish_ready ? "是" : "否"} · 阻塞 ${checklistSummary.blocked_count ?? 0}/${checklistSummary.total ?? 0}\n负责人 ${(checklistSummary.owners || []).join(" / ") || "-"}\n下一步 ${(checklistSummary.next_actions || []).join(" / ") || "-"}\n\n` + + `Author 长线口径:\n入口 ${(status.author_longform_capability || {}).entry_mode || "-"} · 目标 ${(status.author_longform_capability || {}).requested_target_band || "-"} · claim ${(status.author_longform_capability || {}).claim_safe_band || "-"}\nOps ready band ${(status.author_claim_alignment || {}).ops_release_ready_band || "-"} · alignment ${((status.author_claim_alignment || {}).aligned ? "yes" : "no")}\n\n` + + `发布清单明细:\n${checklistDrilldown}\n\n` + + `风险摘要:\n可发布 ${status.risk_summary?.publish_ready ? "是" : "否"}\n门禁 ${((status.risk_summary?.publish_gate_errors) || []).join(" / ") || "-"}\n最近回滚 ${status.risk_summary?.latest_rollback_target || "-"} · ${status.risk_summary?.latest_rollback_reason || "-"}\n权益告警 ${((status.risk_summary?.entitlement_alerts) || []).map((item) => `${item.event_name}:${item.reason || "-"}`).join(" / ") || "-"}\n\n` + + `学习层影子评估:\n状态 ${status.learned_shadow_summary?.status || "-"} · 一致率 ${status.learned_shadow_summary?.agreement_rate !== null && status.learned_shadow_summary?.agreement_rate !== undefined ? Number(status.learned_shadow_summary.agreement_rate).toFixed(3) : "-"}\n问题 ${(status.learned_shadow_summary?.top_mismatch_issue_codes || []).slice(0, 3).map((item) => item.issue_code || item.key).join(" / ") || "-"}\n下一步 ${status.learned_shadow_summary?.recommended_next_action || "-"}\n\n` + + `学习层影子重排:\n状态 ${status.learned_reranker_shadow_summary?.status || "-"} · 准确率 ${status.learned_reranker_shadow_summary?.per_world_accuracy?.[status.world_id] !== undefined ? Number(status.learned_reranker_shadow_summary.per_world_accuracy[status.world_id]).toFixed(3) : "-"}\n下一步 ${status.learned_reranker_shadow_summary?.recommended_next_action || "-"}\n\n` + + `最近审核明细:\n${reviewDrilldown}` + + `${(status.recent_entitlement_events || []).length ? `\n\n最近权益事件:\n${status.recent_entitlement_events.slice(0, 5).map((item) => `${item.event_name} · ${item.reason || "-"} · ${formatTimestamp(item.occurred_at)}`).join("\n")}` : ""}`; + card.innerHTML = renderLocalizedCardHtml( + status.world_id, + status.published_version || "未发布", + worldStatusBody, + rollbackTarget ? `
` : "" + ); if (rollbackTarget) { card.querySelector(".rollback-world").addEventListener("click", async () => { await api(`/v1/ops/worlds/${status.world_id}/rollback`, { @@ -216,50 +591,61 @@ function renderOpsReviewReleaseSection() { }); } card.addEventListener("click", async () => { - appState.selectedOpsWorldId = status.world_id; + opsState.selectedOpsWorldId = status.world_id; syncOpsNavigationContext({ world_id: status.world_id }, { preserveExisting: true }); - if (els.opsReleaseWorldId) { - els.opsReleaseWorldId.value = status.world_id; + if (dom.opsReleaseWorldId) { + dom.opsReleaseWorldId.value = status.world_id; } await refreshOpsReleaseWorkspace(); renderOpsSurface(); }); - els.opsWorldStatus.appendChild(card); + dom.opsWorldStatus.appendChild(card); }); } - if (!appState.opsReleaseWorkspace) { - clearNode(els.opsReleaseWorkspaceSummary, "这里会显示当前 world 的 release summary。"); - clearNode(els.opsReleaseWorkspaceActions, "这里会显示当前 world 的 quick actions。"); - clearNode(els.opsReleaseWorkspaceTimeline, "这里会显示当前 world 的 operator timeline。"); - clearNode(els.opsReleaseWorkspaceDetails, "这里会显示 publish blockers、version matrix 与 rollback workspace。"); + if (!opsState.opsReleaseWorkspace) { + clearNode(dom.opsReleaseWorkspaceSummary, "这里会显示当前世界的发布摘要。"); + clearNode(dom.opsReleaseWorkspaceActions, "这里会显示当前世界的快捷动作。"); + clearNode(dom.opsReleaseWorkspaceTimeline, "这里会显示当前世界的运营时间线。"); + clearNode(dom.opsReleaseWorkspaceDetails, "这里会显示发布阻塞项、版本矩阵与回滚工作台。"); } else { - const release = appState.opsReleaseWorkspace; + const release = opsState.opsReleaseWorkspace; const summary = release.release_summary || {}; const blockers = release.publish_blockers || {}; + const phaseAGate = blockers.phase_a_quality_gate || {}; + const qualityProjection = release.quality_projection_summary || {}; const rollback = release.rollback_workspace || {}; const investigation = release.investigation_summary || {}; - els.opsReleaseWorkspaceSummary.appendChild( + dom.opsReleaseWorkspaceSummary.appendChild( createListCard({ - title: `Release Workspace · ${release.world_id}`, - score: summary.health_status || "-", + title: `发布台 · ${opsWorldLabel(release.world_id)}`, + score: opsStatusLabel(summary.health_status || "-"), body: - `published ${summary.published_version || "-"} · selected ${summary.selected_world_version_id || "-"}\n` + - `publish_ready ${summary.publish_ready ? "yes" : "no"} · blocked ${summary.blocked_checklist_count ?? 0}\n` + - `rollback count ${summary.recent_rollback_count ?? 0} · latest ${summary.latest_rollback_target || "-"}\n` + - `recommended ${summary.recommended_action || "-"}\n` + - `reviewers ${Object.entries(release.review_ownership_summary?.reviewer_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `checklist owners ${(release.review_ownership_summary?.checklist_owners || []).join(" / ") || "-"}\n` + - `investigation ${(investigation.recommended_paths || []).map((item) => item.path_id).join(" / ") || "-"}` + `${opsFieldLine("已发布版本", summary.published_version || "-")} · ${opsFieldLine("当前选中版本", summary.selected_world_version_id || "-")}\n` + + `${opsFieldLine("是否可发布", opsBooleanLabel(summary.publish_ready))} · ${opsFieldLine("阻塞项", summary.blocked_checklist_count ?? 0)}\n` + + `${opsFieldLine("Author 入口", summary.author_entry_mode || "-")} · ${opsFieldLine("目标 band", summary.author_requested_target_band || "-")}\n` + + `${opsFieldLine("Author claim", summary.author_claim_safe_band || "-")} · ${opsFieldLine("Ops ready band", summary.ops_release_ready_band || "-")}\n` + + `${opsFieldLine("Claim alignment", opsBooleanLabel(summary.author_claim_alignment))}\n` + + `${opsFieldLine("质量案例", summary.quality_open_case_count ?? 0)} · ${opsFieldLine("blocked 事件", summary.quality_blocked_event_count ?? 0)} · ${opsFieldLine("review_required 事件", summary.quality_review_required_event_count ?? 0)}\n` + + `${opsFieldLine("最新质量 Trace", summary.quality_latest_trace_id || "-")}\n` + + `${opsFieldLine("策略包批量验证", summary.strategy_bundle_batch_validation_status || "-")} · ${opsFieldLine("验证原因", summary.strategy_bundle_batch_validation_reason || "-")}\n` + + `${opsFieldLine("策略包趋势", summary.strategy_bundle_batch_validation_trend_status || "-")} · ${opsFieldLine("趋势原因", summary.strategy_bundle_batch_validation_trend_reason || "-")}\n` + + `${opsFieldLine("是否建议淘汰", summary.strategy_bundle_batch_validation_retire_recommended ? "yes" : "no")}\n` + + `${opsFieldLine("storybook 标题趋势", summary.reader_storybook_title_homogenization_trend_status || "-")} · ${opsFieldLine("promoted pairs", summary.reader_storybook_title_homogenization_promoted_pair_count ?? 0)}\n` + + `${opsFieldLine("近期回滚次数", summary.recent_rollback_count ?? 0)} · ${opsFieldLine("最近回滚目标", summary.latest_rollback_target || "-")}\n` + + `${opsFieldLine("建议动作", summary.recommended_action || "-")}\n` + + `${opsPairsLine("审阅人分布", release.review_ownership_summary?.reviewer_counts || {})}\n` + + `${opsFieldLine("清单负责人", (release.review_ownership_summary?.checklist_owners || []).join(" / ") || "-")}\n` + + `${opsFieldLine("建议排查路径", (investigation.recommended_paths || []).map((item) => item.path_id).join(" / ") || "-")}` }) ); if (!(release.action_pack || []).length) { - clearNode(els.opsReleaseWorkspaceActions, "这里会显示当前 world 的 quick actions。"); + clearNode(dom.opsReleaseWorkspaceActions, "这里会显示当前世界的快捷动作。"); } else { const actionCard = createListCard({ - title: "Release Actions", + title: "发布动作", score: `${(release.action_pack || []).length} actions`, - body: (release.action_pack || []).map((item) => `${item.label} · ${item.mode}\n${item.reason || "-"}`).join("\n\n"), + body: (release.action_pack || []).map((item) => `${item.label} · ${opsActionModeLabel(item.mode)}\n${item.reason || "-"}`).join("\n\n"), }); const actions = document.createElement("div"); actions.className = "composer-actions"; @@ -269,18 +655,18 @@ function renderOpsReviewReleaseSection() { button.textContent = item.label; button.addEventListener("click", async () => { try { - await runOpsReleaseWorkspaceAction(item); + await OpsActionsRuntime.runOpsReleaseWorkspaceAction(item); } catch (error) { alert(`执行 release action 失败:${error.message}`); } }); actions.appendChild(button); }); - els.opsReleaseWorkspaceActions.appendChild(actionCard); - els.opsReleaseWorkspaceActions.appendChild(actions); + dom.opsReleaseWorkspaceActions.appendChild(actionCard); + dom.opsReleaseWorkspaceActions.appendChild(actions); } if (!(release.operator_timeline || []).length) { - clearNode(els.opsReleaseWorkspaceTimeline, "这里会显示当前 world 的 operator timeline。"); + clearNode(dom.opsReleaseWorkspaceTimeline, "这里会显示当前世界的运营时间线。"); } else { (release.operator_timeline || []).forEach((item) => { const card = document.createElement("article"); @@ -292,313 +678,628 @@ function renderOpsReviewReleaseSection() {

${formatTimestamp(item.occurred_at)}\n${item.summary || "-"}\nnext ${(item.next_actions || []).join(" / ") || "-"}

`; - els.opsReleaseWorkspaceTimeline.appendChild(card); + dom.opsReleaseWorkspaceTimeline.appendChild(card); }); } - const detailBody = [ - `publish blockers:\n${(blockers.items || []).map((item) => summarizeReleaseBlocker(item)).join("\n\n") || "-"}`, - `version matrix:\n${(release.version_matrix || []).map((item) => `${item.world_version_id}\n${item.status} · decision ${item.latest_decision || "-"} · publish_ready ${item.publish_ready ? "yes" : "no"}\ncross-pack ${Number(item.cross_pack_pass_rate || 0).toFixed(3)} · block ${formatPercent(item.block_rate)} · regress ${item.regression_detected ? "yes" : "no"}\nweakest ${(item.top_failing_pack_ids || []).join(" / ") || "-"}\ngate ${(item.publish_gate_errors || []).join(" / ") || "-"}\nupdated ${formatTimestamp(item.updated_at)}`).join("\n\n") || "-"}`, - `rollback workspace:\nlatest ${rollback.latest_rollback?.rollback_target_world_version_id || "-"} · ${rollback.latest_rollback?.rollback_reason || "-"}\ncandidates ${(rollback.rollback_candidates || []).map((item) => `${item.world_version_id}:${item.status}`).join(" / ") || "-"}\nsummary count ${(rollback.summary || {}).total_entries ?? 0} · latest reason ${(rollback.summary || {}).latest_reason || "-"}`, - ].join("\n\n"); - els.opsReleaseWorkspaceDetails.appendChild( - createListCard({ - title: "Release Drill-down", + const detailBody = [ + `publish blockers:\n${(blockers.items || []).map((item) => summarizeReleaseBlocker(item)).join("\n\n") || "-"}`, + `author longform capability:\nentry ${(release.release_evidence_bundle?.author_longform_capability || {}).entry_mode || "-"} · requested ${(release.release_evidence_bundle?.author_longform_capability || {}).requested_target_band || "-"} · claim ${(release.release_evidence_bundle?.author_longform_capability || {}).claim_safe_band || "-"}\nstatus ${((release.release_evidence_bundle?.author_longform_capability || {}).longform_readiness || {}).status || "-"}\nblockers ${(((release.release_evidence_bundle?.author_longform_capability || {}).longform_readiness || {}).blockers || []).map((item) => item.message || item.key).join(" / ") || "-"}\nstructure ${((release.release_evidence_bundle?.author_longform_capability || {}).structure_counts || {}).character_count ?? 0} 角色 / ${((release.release_evidence_bundle?.author_longform_capability || {}).structure_counts || {}).scene_blueprint_count ?? 0} 场景 / ${((release.release_evidence_bundle?.author_longform_capability || {}).structure_counts || {}).location_count ?? 0} 地点`, + `author claim alignment:\nclaim ${(release.release_evidence_bundle?.author_claim_alignment || {}).claim_safe_band || "-"} · ops ready ${(release.release_evidence_bundle?.author_claim_alignment || {}).ops_release_ready_band || "-"}\nalignment ${((release.release_evidence_bundle?.author_claim_alignment || {}).aligned ? "yes" : "no")} · reason ${(release.release_evidence_bundle?.author_claim_alignment || {}).reason || "-"}`, + `strategy bundle batch validation:\nstrategy ${(release.release_evidence_bundle?.strategy_bundle_batch_validation_summary || {}).strategy_bundle_label || "-"} ${(release.release_evidence_bundle?.strategy_bundle_batch_validation_summary || {}).strategy_bundle_id ? `(${(release.release_evidence_bundle?.strategy_bundle_batch_validation_summary || {}).strategy_bundle_id})` : ""}\nstatus ${summary.strategy_bundle_batch_validation_status || "-"} · decision reason ${summary.strategy_bundle_batch_validation_reason || "-"}\nvalidated worlds ${(release.release_evidence_bundle?.strategy_bundle_batch_validation_summary || {}).validated_world_count ?? 0} · effectiveness ${Number((release.release_evidence_bundle?.strategy_bundle_batch_validation_summary || {}).effectiveness_rate || 0).toFixed(3)}\ncompatible worlds ${((release.release_evidence_bundle?.strategy_bundle_batch_validation_summary || {}).compatible_world_ids || []).join(" / ") || "-"}\nadaptation ${((release.release_evidence_bundle?.strategy_bundle_batch_validation_summary || {}).top_adaptation_targets || []).map((item) => `${item.kind}:${item.name}=${item.count}`).join(" / ") || "-"}`, + `strategy bundle history:\ntrend ${(release.release_evidence_bundle?.strategy_bundle_batch_validation_history_summary || {}).trend_status || "-"} · reason ${(release.release_evidence_bundle?.strategy_bundle_batch_validation_history_summary || {}).trend_reason || "-"}\nlatest ${(release.release_evidence_bundle?.strategy_bundle_batch_validation_history_summary || {}).latest_decision || "-"} · effectiveness ${Number((release.release_evidence_bundle?.strategy_bundle_batch_validation_history_summary || {}).latest_effectiveness_rate || 0).toFixed(3)} · delta ${Number((release.release_evidence_bundle?.strategy_bundle_batch_validation_history_summary || {}).delta_effectiveness_rate || 0).toFixed(3)}\nretire recommended ${((release.release_evidence_bundle?.strategy_bundle_batch_validation_history_summary || {}).retire_recommended ? "yes" : "no")}\nrecent runs ${((release.release_evidence_bundle?.strategy_bundle_batch_validation_history || {}).entries || []).map((item) => `${item.generated_at || "-"}:${item.decision || "-"}:${Number(item.effectiveness_rate || 0).toFixed(3)}`).join(" / ") || "-"}`, + `reader storybook title trend:\ntrend ${(release.release_evidence_bundle?.reader_storybook_title_homogenization_history_summary || {}).trend_status || "-"} · reason ${(release.release_evidence_bundle?.reader_storybook_title_homogenization_history_summary || {}).trend_reason || "-"}\nlatest ${(release.release_evidence_bundle?.reader_storybook_title_homogenization_history_summary || {}).latest_generated_at || "-"} · promoted ${(release.release_evidence_bundle?.reader_storybook_title_homogenization_history_summary || {}).promoted_pair_count ?? 0}\npairs ${((release.release_evidence_bundle?.reader_storybook_title_homogenization_promoted_pairs || []).map((item) => `${item.non_jade_world_id}->${item.jade_world_id}@${item.consecutive_warning_count}`).join(" / ")) || "-"}`, + `版本矩阵:\n${(release.version_matrix || []).map((item) => `${item.world_version_id}\n${item.status} · 最近判断 ${item.latest_decision || "-"} · 是否可发布 ${item.publish_ready ? "是" : "否"}\n跨包 ${Number(item.cross_pack_pass_rate || 0).toFixed(3)} · 阻塞 ${formatPercent(item.block_rate)} · 是否回退 ${item.regression_detected ? "是" : "否"}\n薄弱世界 ${(item.top_failing_pack_ids || []).join(" / ") || "-"}\n门禁 ${(item.publish_gate_errors || []).join(" / ") || "-"}\n更新时间 ${formatTimestamp(item.updated_at)}`).join("\n\n") || "-"}`, + `回滚工作台:\n最近回滚 ${rollback.latest_rollback?.rollback_target_world_version_id || "-"} · ${rollback.latest_rollback?.rollback_reason || "-"}\n候选版本 ${(rollback.rollback_candidates || []).map((item) => `${item.world_version_id}:${item.status}`).join(" / ") || "-"}\n摘要数量 ${(rollback.summary || {}).total_entries ?? 0} · 最近原因 ${(rollback.summary || {}).latest_reason || "-"}`, + ].join("\n\n"); + const detailCard = createListCard({ + title: "发布详情", score: `${(blockers.items || []).length} blockers`, body: detailBody, - }) - ); + }); + if (opsState.selectedOpsReleaseBlockerKey && opsState.selectedOpsReleaseBlockerKey !== "phase_a_quality_gate") { + detailCard.dataset.releaseBlockerKey = opsState.selectedOpsReleaseBlockerKey; + } + dom.opsReleaseWorkspaceDetails.appendChild(detailCard); + if (phaseAGate.available) { + const summaryCard = createListCard({ + title: "Phase A Quality Gate", + score: `${(phaseAGate.failed_check_items || []).length} failed`, + body: + `${opsFieldLine("配置版本", phaseAGate.config_version || "-")}\n` + + `${opsFieldLine("失败项", (phaseAGate.failed_check_items || []).map((item) => item.check_key).join(" / ") || "-")}` + }); + summaryCard.dataset.releaseBlockerKey = "phase_a_quality_gate"; + dom.opsReleaseWorkspaceDetails.appendChild(summaryCard); + if ((qualityProjection.events || []).length) { + const qualityCard = createListCard({ + title: "质量投影", + score: `${qualityProjection.summary?.open_review_case_count ?? 0} open`, + body: + `${opsFieldLine("事件数", qualityProjection.summary?.event_count ?? 0)} · ${opsFieldLine("案例数", qualityProjection.summary?.review_case_count ?? 0)}\n` + + `${opsFieldLine("blocked", qualityProjection.summary?.blocked_event_count ?? 0)} · ${opsFieldLine("review_required", qualityProjection.summary?.review_required_event_count ?? 0)}\n` + + `最近 Trace:\n${(qualityProjection.events || []).slice(0, 3).map((item) => `${item.trace_id || "-"} · ${opsStatusLabel(item.status || "-")} · ${opsIssueCodeList(item.reason_codes || [])}`).join("\n") || "-"}` + }); + dom.opsReleaseWorkspaceDetails.appendChild(qualityCard); + } + (phaseAGate.failed_check_items || []).forEach((item) => { + const card = createListCard({ + title: item.label || item.check_key, + score: "blocked", + body: + `${opsFieldLine("原因", item.reason || "-")}\n` + + `${opsFieldLine("阈值", item.threshold !== undefined && item.threshold !== null ? item.threshold : "-")}\n` + + `${opsFieldLine("实际值", Array.isArray(item.actual) ? JSON.stringify(item.actual) : (item.actual !== undefined && item.actual !== null ? item.actual : "-"))}\n` + + `${opsFieldLine("涉及 worlds", (item.evaluated_world_ids || []).map((worldId) => opsWorldLabel(worldId)).join(" / ") || "-")}` + }); + card.dataset.releaseBlockerKey = "phase_a_quality_gate"; + card.dataset.releaseBlockerCheckKey = item.check_key || ""; + if ( + opsState.selectedOpsReleaseBlockerKey === "phase_a_quality_gate" + && opsState.selectedOpsReleaseBlockerCheckKey + && opsState.selectedOpsReleaseBlockerCheckKey === item.check_key + ) { + card.classList.add("is-highlighted"); + } + dom.opsReleaseWorkspaceDetails.appendChild(card); + }); + } } - clearNode(els.opsReviewHistory); - if (!appState.opsWorldHistories.length) { - clearNode(els.opsReviewHistory, "这里会显示 world version 的审核、发布和回滚记录。"); + clearNode(dom.opsReviewHistory); + if (!opsState.opsWorldHistories.length) { + clearNode(dom.opsReviewHistory, "这里会显示世界版本的审核、发布和回滚记录。"); } else { - appState.opsWorldHistories.forEach((history) => { + opsState.opsWorldHistories.forEach((history) => { const card = document.createElement("article"); card.className = "list-card"; const summary = history.review_summary || {}; const rollbackSummary = history.rollback_summary || {}; const timelineBody = (history.review_timeline || []).slice(0, 8).map((item) => summarizeReviewTimelineEntry(item)).join("\n\n") || "暂无审核记录"; const rollbackBody = (history.rollback_drilldown || []).slice(0, 5).map((item) => summarizeRollbackEntry(item)).join("\n\n") || "暂无 rollback 记录"; - card.innerHTML = ` -
-

${history.world_id}

- ${summary.total_entries ?? (history.review_history || []).length} 条 -
-

summary:\nstatus ${Object.entries(summary.status_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\nreviewers ${Object.entries(summary.reviewer_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\nlatest published ${summary.latest_published_world_version_id || "-"}\nlatest blocked ${summary.latest_blocked_world_version_id || "-"}\nlatest rollback ${summary.latest_rollback_target_world_version_id || "-"}\n\nrollback summary:\ncount ${rollbackSummary.total_entries ?? 0}\ntargets ${Object.entries(rollbackSummary.target_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\nreviewers ${Object.entries(rollbackSummary.reviewer_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\nlatest reason ${rollbackSummary.latest_reason || "-"}\n\nreview timeline:\n${timelineBody}\n\nrollback drill-down:\n${rollbackBody}

- `; - els.opsReviewHistory.appendChild(card); + const reviewHistoryBody = + `摘要:\n状态 ${Object.entries(summary.status_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n审阅人 ${Object.entries(summary.reviewer_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n最近发布 ${summary.latest_published_world_version_id || "-"}\n最近阻塞 ${summary.latest_blocked_world_version_id || "-"}\n最近回滚 ${summary.latest_rollback_target_world_version_id || "-"}\n\n` + + `回滚摘要:\n数量 ${rollbackSummary.total_entries ?? 0}\n目标 ${Object.entries(rollbackSummary.target_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n审阅人 ${Object.entries(rollbackSummary.reviewer_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n最近原因 ${rollbackSummary.latest_reason || "-"}\n\n` + + `审核时间线:\n${timelineBody}\n\n回滚明细:\n${rollbackBody}`; + card.innerHTML = renderLocalizedCardHtml(history.world_id, `${summary.total_entries ?? (history.review_history || []).length} 条`, reviewHistoryBody); + dom.opsReviewHistory.appendChild(card); }); } - clearNode(els.opsQualityTrend); - if (!appState.opsWorldHistories.length) { - clearNode(els.opsQualityTrend, "这里会显示每个 world version 的 pass / rewrite / block 与 cross-pack 走势。"); + clearNode(dom.opsQualityTrend); + if (!opsState.opsWorldHistories.length) { + clearNode(dom.opsQualityTrend, "这里会显示每个世界版本的通过、重写、阻塞与跨包走势。"); } else { - appState.opsWorldHistories.forEach((history) => { + opsState.opsWorldHistories.forEach((history) => { const card = document.createElement("article"); card.className = "list-card"; const summary = history.quality_trend_summary || {}; - card.innerHTML = ` -
-

${history.world_id}

- ${(history.quality_trend || []).length} 条 -
-

summary:\nlatest ${summary.latest_world_version_id || "-"}\nstrongest ${summary.strongest_world_version_id || "-"}\nweakest ${summary.weakest_world_version_id || "-"}\nregressions ${(summary.regression_version_ids || []).join(" / ") || "-"}\nblocked ${(summary.blocked_version_ids || []).join(" / ") || "-"}\nimproving ${(summary.improving_version_ids || []).join(" / ") || "-"}\nlatest delta pass ${formatSignedDelta(summary.latest_delta?.pass_rate)} · block ${formatSignedDelta(summary.latest_delta?.block_rate)} · cross-pack ${formatSignedDelta(summary.latest_delta?.cross_pack_pass_rate)}\n\ntrend drill-down:\n${(history.quality_trend || []).map((item) => summarizeQualityTrendEntry(item)).join("\n\n") || "暂无版本级质量趋势。"}

- `; - els.opsQualityTrend.appendChild(card); + const qualityBody = + `摘要:\n最近版本 ${summary.latest_world_version_id || "-"}\n最强版本 ${summary.strongest_world_version_id || "-"}\n最弱版本 ${summary.weakest_world_version_id || "-"}\n回退版本 ${(summary.regression_version_ids || []).join(" / ") || "-"}\n阻塞版本 ${(summary.blocked_version_ids || []).join(" / ") || "-"}\n改善版本 ${(summary.improving_version_ids || []).join(" / ") || "-"}\n最近变化 通过率 ${formatSignedDelta(summary.latest_delta?.pass_rate)} · 阻塞率 ${formatSignedDelta(summary.latest_delta?.block_rate)} · 跨包 ${formatSignedDelta(summary.latest_delta?.cross_pack_pass_rate)}\n\n趋势明细:\n${(history.quality_trend || []).map((item) => summarizeQualityTrendEntry(item)).join("\n\n") || "暂无版本级质量趋势。"}`; + card.innerHTML = renderLocalizedCardHtml(history.world_id, `${(history.quality_trend || []).length} 条`, qualityBody); + dom.opsQualityTrend.appendChild(card); }); } } function renderOpsRuntimeSection() { - clearNode(els.opsSchemaLifecycle); - clearNode(els.opsDataIntegrity); - if (!appState.opsSchemaLifecycle) { - clearNode(els.opsSchemaLifecycle, "这里会显示当前数据库 backend、migration pending 状态和 schema drift 摘要。"); - clearNode(els.opsDataIntegrity, "这里会显示热点索引覆盖、session drift、orphan route choices 与 repair backlog。"); + clearNode(dom.opsSchemaLifecycle); + clearNode(dom.opsDataIntegrity); + if (!opsState.opsSchemaLifecycle) { + clearNode(dom.opsSchemaLifecycle, "这里会显示当前数据库后端、待执行迁移状态和结构漂移摘要。"); + clearNode(dom.opsDataIntegrity, "这里会显示热点索引覆盖、会话漂移、孤儿分支选择与修复待办。"); } else { - const lifecycle = appState.opsSchemaLifecycle; - els.opsSchemaLifecycle.appendChild( + const lifecycle = opsState.opsSchemaLifecycle; + dom.opsSchemaLifecycle.appendChild( createListCard({ - title: "Schema Lifecycle", - score: lifecycle.status || "-", - body: - `backend ${lifecycle.backend || "-"}\n` + - `latest available ${lifecycle.latest_available_version || "-"} · latest applied ${lifecycle.latest_applied_version || "-"}\n` + - `pending ${(lifecycle.pending_versions || []).join(" / ") || "-"}\n` + - `schema matches migrations ${lifecycle.schema_matches_migrations ? "yes" : "no"}\n` + - `alembic ${lifecycle.alembic?.status || "-"} · current ${lifecycle.alembic?.current_revision || "-"} · head ${lifecycle.alembic?.head_revision || "-"}\n` + - `schema fp ${(lifecycle.schema_sql_fingerprint || "-").slice(0, 12)}\n` + - `migrations fp ${(lifecycle.migrations_fingerprint || "-").slice(0, 12)}` + title: "数据库结构生命周期", + score: opsStatusLabel(lifecycle.status || "-"), + body: opsSections( + opsSection("基础信息", [ + opsFieldLine("后端", lifecycle.backend || "-"), + opsFieldLine("最新可用版本", lifecycle.latest_available_version || "-"), + opsFieldLine("最新已应用版本", lifecycle.latest_applied_version || "-"), + opsFieldLine("待应用版本", (lifecycle.pending_versions || []).join(" / ") || "-"), + ]), + opsSection("一致性", [ + opsFieldLine("结构是否与迁移一致", opsBooleanLabel(lifecycle.schema_matches_migrations)), + opsFieldLine("Alembic 状态", opsStatusLabel(lifecycle.alembic?.status || "-")), + opsFieldLine("当前 Alembic 版本", lifecycle.alembic?.current_revision || "-"), + opsFieldLine("目标 Alembic 版本", lifecycle.alembic?.head_revision || "-"), + ]), + opsSection("指纹", [ + opsFieldLine("数据库结构指纹", (lifecycle.schema_sql_fingerprint || "-").slice(0, 12)), + opsFieldLine("迁移指纹", (lifecycle.migrations_fingerprint || "-").slice(0, 12)), + ]) + ) }) ); } - if (!appState.opsDataIntegrity) { - clearNode(els.opsDataIntegrity, "这里会显示热点索引覆盖、session drift、orphan route choices 与 repair backlog。"); + if (!opsState.opsDataIntegrity) { + clearNode(dom.opsDataIntegrity, "这里会显示热点索引覆盖、会话漂移、孤儿分支选择与修复待办。"); } else { - const integrity = appState.opsDataIntegrity; - const repairResult = appState.opsDataIntegrityRepair; - els.opsDataIntegrity.appendChild( + const integrity = opsState.opsDataIntegrity; + const repairResult = opsState.opsDataIntegrityRepair; + dom.opsDataIntegrity.appendChild( createListCard({ - title: "Data Integrity / Repair", - score: integrity.status || "-", - body: - `backend ${integrity.backend || "-"} · schema ${integrity.schema_lifecycle?.status || "-"}\n` + - `indexes ${integrity.hotspot_index_summary?.covered_count ?? 0}/${integrity.hotspot_index_summary?.expected_count ?? 0} · missing ${integrity.hotspot_index_summary?.missing_count ?? 0}\n` + - `session drift ${integrity.concurrency_summary?.session_pointer_drift_count ?? 0} · orphan choices ${integrity.concurrency_summary?.orphan_route_choice_count ?? 0}\n` + - `duplicate active subscriptions ${integrity.concurrency_summary?.duplicate_active_subscription_count ?? 0}\n` + - `warnings ${(integrity.warnings || []).join(" / ") || "-"}\n\n` + - `safe repairs:\n${(integrity.repair_actions || []).map((item) => `${item.action} · ${item.target_count} · ${item.reason || "-"}`).join("\n") || "-"}\n\n` + - `manual backlog:\n${(integrity.manual_backlog || []).map((item) => `${item.action} · ${item.target_count} · ${item.reason || "-"}`).join("\n") || "-"}${ - repairResult - ? `\n\nlast repair ${repairResult.apply ? "apply" : "dry-run"} · changed ${repairResult.changed ? "yes" : "no"}\n${(repairResult.action_results || []).map((item) => `${item.action}: ${item.applied_count ?? 0}/${item.planned_count ?? 0}`).join(" / ") || "-"}` - : "" - }` + title: "数据一致性与修复", + score: opsStatusLabel(integrity.status || "-"), + body: opsSections( + opsSection("总体状态", [ + opsFieldLine("后端", integrity.backend || "-"), + opsFieldLine("数据库结构状态", opsStatusLabel(integrity.schema_lifecycle?.status || "-")), + opsFieldLine("热点索引覆盖", `${integrity.hotspot_index_summary?.covered_count ?? 0}/${integrity.hotspot_index_summary?.expected_count ?? 0}`), + opsFieldLine("缺失索引", integrity.hotspot_index_summary?.missing_count ?? 0), + ]), + opsSection("并发与漂移", [ + opsFieldLine("会话指针漂移", integrity.concurrency_summary?.session_pointer_drift_count ?? 0), + opsFieldLine("孤儿选项", integrity.concurrency_summary?.orphan_route_choice_count ?? 0), + opsFieldLine("重复生效中的订阅", integrity.concurrency_summary?.duplicate_active_subscription_count ?? 0), + opsFieldLine("告警", (integrity.warnings || []).join(" / ") || "-"), + ]), + opsSection("安全修复动作", [ + opsList(integrity.repair_actions || [], (item) => `${item.action} · ${item.target_count} · ${item.reason || "-"}`), + ]), + opsSection("人工处理待办", [ + opsList(integrity.manual_backlog || [], (item) => `${item.action} · ${item.target_count} · ${item.reason || "-"}`), + ]), + repairResult ? opsSection("最近一次修复", [ + opsFieldLine("模式", repairResult.apply ? "正式应用" : "仅演练"), + opsFieldLine("是否有变更", opsBooleanLabel(repairResult.changed)), + opsFieldLine("动作结果", (repairResult.action_results || []).map((item) => `${item.action}:${item.applied_count ?? 0}/${item.planned_count ?? 0}`).join(" / ") || "-"), + ]) : "" + ) }) ); } - clearNode(els.opsDeploymentRunbook); - clearNode(els.opsIncidentPlaybook); - clearNode(els.opsDeploymentHealthGate); - clearNode(els.opsPreflightVerification); - if (!appState.opsDeploymentRunbook) { - clearNode(els.opsDeploymentHealthGate, "这里会显示 deployment health gate 和总体放行状态。"); - clearNode(els.opsPreflightVerification, "这里会显示 preflight verification bundle 与推荐验证命令。"); - clearNode(els.opsDeploymentRunbook, "这里会显示 deployment runbook 与最近 backups。"); - clearNode(els.opsIncidentPlaybook, "这里会显示 incident playbook 与建议恢复步骤。"); + clearNode(dom.opsDeploymentRunbook); + clearNode(dom.opsIncidentPlaybook); + clearNode(dom.opsDeploymentHealthGate); + clearNode(dom.opsPreflightVerification); + if (!opsState.opsDeploymentRunbook) { + clearNode(dom.opsDeploymentHealthGate, "这里会显示发布健康门和总体放行状态。"); + clearNode(dom.opsPreflightVerification, "这里会显示发布前校验包与推荐验证命令。"); + clearNode(dom.opsDeploymentRunbook, "这里会显示发布运行手册与最近备份。"); + clearNode(dom.opsIncidentPlaybook, "这里会显示事故处置手册与建议恢复步骤。"); } else { - const healthGate = appState.opsDeploymentHealthGate || {}; - els.opsDeploymentHealthGate.appendChild( + const healthGate = opsState.opsDeploymentHealthGate || {}; + dom.opsDeploymentHealthGate.appendChild( createListCard({ - title: "Deployment Health Gate", - score: healthGate.status || "-", - body: - `recommended ${healthGate.recommended_action || "-"}\n` + - `checks ${((healthGate.checks || []).map((item) => `${item.key}:${item.status}`).join(" / ")) || "-"}\n` + - `schema ${healthGate.schema_lifecycle?.status || "-"} · incidents ${healthGate.incident_snapshot?.incident_count ?? 0}\n` + - `provider ${(Object.entries(healthGate.incident_snapshot?.by_provider || {}).map(([key, value]) => `${key}=${value}`).join(" / ")) || "-"}` + title: "发布健康门", + score: opsStatusLabel(healthGate.status || "-"), + body: opsSections( + opsSection("总体判断", [ + opsFieldLine("建议动作", healthGate.recommended_action || "-"), + opsFieldLine("检查项", ((healthGate.checks || []).map((item) => `${item.key}:${opsStatusLabel(item.status)}`).join(" / ")) || "-"), + ]), + opsSection("结构与事故", [ + opsFieldLine("数据库结构状态", opsStatusLabel(healthGate.schema_lifecycle?.status || "-")), + opsFieldLine("事故数", healthGate.incident_snapshot?.incident_count ?? 0), + opsPairsLine("通道分布", healthGate.incident_snapshot?.by_provider || {}, opsProviderLabel), + ]) + ) }) ); - const preflight = appState.opsPreflightVerification || {}; - els.opsPreflightVerification.appendChild( + const preflight = opsState.opsPreflightVerification || {}; + dom.opsPreflightVerification.appendChild( createListCard({ - title: "Preflight Verification Bundle", - score: preflight.verification_summary?.gate_status || "-", - body: - `recommended ${preflight.verification_summary?.recommended_action || "-"}\n` + - `schema ${preflight.verification_summary?.schema_status || "-"} · incidents ${preflight.verification_summary?.incident_count ?? 0}\n` + - `restore verify:\n${(preflight.restore_verification_steps || []).join("\n") || "-"}\n\n` + - `commands:\n${(preflight.verification_commands || []).join("\n") || "-"}` + title: "发布前校验包", + score: opsStatusLabel(preflight.verification_summary?.gate_status || "-"), + body: opsSections( + opsSection("总体判断", [ + opsFieldLine("建议动作", preflight.verification_summary?.recommended_action || "-"), + opsFieldLine("数据库结构状态", opsStatusLabel(preflight.verification_summary?.schema_status || "-")), + opsFieldLine("事故数", preflight.verification_summary?.incident_count ?? 0), + ]), + opsSection("恢复验证步骤", [ + opsList(preflight.restore_verification_steps || []), + ]), + opsSection("建议命令", [ + opsList(preflight.verification_commands || []), + ]) + ) }) ); - const runbook = appState.opsDeploymentRunbook; - els.opsDeploymentRunbook.appendChild( + const runbook = opsState.opsDeploymentRunbook; + dom.opsDeploymentRunbook.appendChild( createListCard({ - title: "Deployment Runbook", - score: runbook.schema_lifecycle?.status || runbook.backend || "-", - body: - `backend ${runbook.backend || "-"}\n` + - `db ${runbook.database_url || "-"}\n` + - `preflight ${((runbook.preflight_checks || []).map((item) => `${item.key}:${item.ok ? "ok" : item.reason}`).join(" / ")) || "-"}\n\n` + - `deploy steps:\n${(runbook.deploy_steps || []).join("\n") || "-"}\n\n` + - `rollback steps:\n${(runbook.rollback_steps || []).join("\n") || "-"}\n\n` + - `restore verify:\n${(runbook.restore_verification_steps || []).join("\n") || "-"}\n\n` + - `restore hints:\n${(runbook.restore_decision_hints || []).join("\n") || "-"}\n\n` + - `restore requests:\n${(runbook.recent_restore_requests || []).map((item) => `${item.request_id} · ${item.approval_status || item.latest_status || "-"}\nrequested ${item.requested_by || "-"} · approved ${item.approved_by || "-"} · executed ${item.executed_by || "-"}\nexpires ${item.approval_expires_at || "-"}\n${item.backup_format || "-"} · ${item.target_database_identity || "-"}\n${item.reason || "-"}\njob ${item.executed_job_id || "-"} · artifact ${item.artifact_path || "-"}`).join("\n\n") || "-"}\n\n` + - `restore jobs:\n${(runbook.recent_restore_jobs || []).map((item) => `${item.job_id} · ${item.status || "-"}\nrequest ${item.payload?.request_id || "-"} · artifact ${(item.result_summary || {}).result_json || (item.result_summary || {}).artifact_dir || "-"}`).join("\n\n") || "-"}\n\n` + - `recent recovery drills:\n${(runbook.recent_recovery_drills || []).map((item) => `${item.drill_id || "-"} · ${item.status || "-"}\n${item.backup_path || "-"}\nartifact ${item.artifact_path || "-"}`).join("\n\n") || "-"}\n\n` + - `recent backups:\n${(runbook.recent_backups || []).map((item) => `${item.backup_id} · ${item.status}\n${item.backup_path || "-"} · ${item.created_at}`).join("\n\n") || "-"}` + title: "发布运行手册", + score: opsStatusLabel(runbook.schema_lifecycle?.status || runbook.backend || "-"), + body: opsSections( + opsSection("基础信息", [ + opsFieldLine("后端", runbook.backend || "-"), + opsFieldLine("数据库地址", runbook.database_url || "-"), + opsFieldLine("预检查结果", ((runbook.preflight_checks || []).map((item) => `${item.key}:${item.ok ? "通过" : item.reason}`).join(" / ")) || "-"), + ]), + opsSection("发布步骤", [opsList(runbook.deploy_steps || [])]), + opsSection("回滚步骤", [opsList(runbook.rollback_steps || [])]), + opsSection("恢复验证步骤", [opsList(runbook.restore_verification_steps || [])]), + opsSection("恢复提示", [opsList(runbook.restore_decision_hints || [])]), + opsSection("最近恢复请求", [ + opsParagraphList(runbook.recent_restore_requests || [], (item) => + `${item.request_id} · ${opsStatusLabel(item.approval_status || item.latest_status || "-")}\n` + + `${opsFieldLine("发起人", item.requested_by || "-")} · ${opsFieldLine("批准人", item.approved_by || "-")} · ${opsFieldLine("执行人", item.executed_by || "-")}\n` + + `${opsFieldLine("审批有效期", item.approval_expires_at || "-")}\n` + + `${opsFieldLine("备份格式", item.backup_format || "-")} · ${opsFieldLine("目标数据库", item.target_database_identity || "-")}\n` + + `${opsFieldLine("原因", item.reason || "-")}\n` + + `${opsFieldLine("任务", item.executed_job_id || "-")} · ${opsFieldLine("产物", item.artifact_path || "-")}` + ), + ]), + opsSection("最近恢复任务", [ + opsParagraphList(runbook.recent_restore_jobs || [], (item) => + `${item.job_id} · ${opsStatusLabel(item.status || "-")}\n` + + `${opsFieldLine("请求 ID", item.payload?.request_id || "-")} · ${opsFieldLine("产物", (item.result_summary || {}).result_json || (item.result_summary || {}).artifact_dir || "-")}` + ), + ]), + opsSection("最近恢复演练", [ + opsParagraphList(runbook.recent_recovery_drills || [], (item) => + `${item.drill_id || "-"} · ${opsStatusLabel(item.status || "-")}\n` + + `${opsFieldLine("备份路径", item.backup_path || "-")}\n` + + `${opsFieldLine("产物", item.artifact_path || "-")}` + ), + ]), + opsSection("最近备份", [ + opsParagraphList(runbook.recent_backups || [], (item) => + `${item.backup_id} · ${opsStatusLabel(item.status || "-")}\n` + + `${opsFieldLine("备份路径", item.backup_path || "-")} · ${opsFieldLine("创建时间", item.created_at || "-")}` + ), + ]) + ) }) ); - if (!els.opsRestorePath?.value && runbook.recent_backups?.[0]?.backup_path && els.opsRestorePath) { - els.opsRestorePath.value = runbook.recent_backups[0].backup_path; + if (!dom.opsRestorePath?.value && runbook.recent_backups?.[0]?.backup_path && dom.opsRestorePath) { + dom.opsRestorePath.value = runbook.recent_backups[0].backup_path; } - if (!els.opsRestoreRequestId?.value && runbook.recent_restore_requests?.[0]?.request_id && els.opsRestoreRequestId) { - els.opsRestoreRequestId.value = runbook.recent_restore_requests[0].request_id; + if (!dom.opsRestoreRequestId?.value && runbook.recent_restore_requests?.[0]?.request_id && dom.opsRestoreRequestId) { + dom.opsRestoreRequestId.value = runbook.recent_restore_requests[0].request_id; } } - if (!appState.opsIncidentPlaybook) { - clearNode(els.opsIncidentPlaybook, "这里会显示 incident playbook 与建议恢复步骤。"); + if (!opsState.opsIncidentPlaybook) { + clearNode(dom.opsIncidentPlaybook, "这里会显示事故处置手册与建议恢复步骤。"); } else { - const playbook = appState.opsIncidentPlaybook; - els.opsIncidentPlaybook.appendChild( + const playbook = opsState.opsIncidentPlaybook; + dom.opsIncidentPlaybook.appendChild( createListCard({ - title: "Incident Playbook", - score: `${playbook.incident_snapshot?.incident_count ?? 0} incidents`, + title: "事故处置手册", + score: `${playbook.incident_snapshot?.incident_count ?? 0} 起事故`, body: - `schema ${playbook.deployment_runbook?.schema_lifecycle?.status || "-"}\n` + - `restore hints ${(playbook.deployment_runbook?.restore_decision_hints || []).join(" / ") || "-"}\n` + - `triage:\n${(playbook.triage_steps || []).join("\n") || "-"}\n\n` + - `recovery:\n${(playbook.recovery_steps || []).join("\n") || "-"}\n\n` + - `restore verify:\n${(playbook.restore_verification_steps || []).join("\n") || "-"}\n\n` + - `decision matrix:\n${(playbook.decision_matrix || []).map((item) => `${item.preferred_action} · ${item.when ? "active" : "standby"}\n${item.scenario}\ninspect ${(item.inspect || []).join(" / ") || "-"}`).join("\n\n") || "-"}` + `数据库结构 ${playbook.deployment_runbook?.schema_lifecycle?.status || "-"}\n` + + `恢复提示 ${(playbook.deployment_runbook?.restore_decision_hints || []).join(" / ") || "-"}\n` + + `分诊步骤:\n${(playbook.triage_steps || []).join("\n") || "-"}\n\n` + + `恢复步骤:\n${(playbook.recovery_steps || []).join("\n") || "-"}\n\n` + + `恢复校验:\n${(playbook.restore_verification_steps || []).join("\n") || "-"}\n\n` + + `决策矩阵:\n${(playbook.decision_matrix || []).map((item) => `${item.preferred_action} · ${item.when ? "立即执行" : "待命观察"}\n${item.scenario}\n检查项 ${(item.inspect || []).join(" / ") || "-"}`).join("\n\n") || "-"}` }) ); } - clearNode(els.opsRuntimeIncidentSnapshot); - clearNode(els.opsRuntimeReceipts); - clearNode(els.opsProviderRouting); - clearNode(els.opsProviderRollout); - clearNode(els.opsProviderRuntimeMetrics); - if (!appState.opsRuntimeIncidentSnapshot) { - clearNode(els.opsRuntimeIncidentSnapshot, "这里会显示 runtime incident snapshot、provider fallback、budget block 与 cache hit 概况。"); - clearNode(els.opsRuntimeReceipts, "这里会显示最近的 runtime receipts。"); - clearNode(els.opsProviderRouting, "这里会显示 candidate / renderer 当前的 routing policy。"); - clearNode(els.opsProviderRollout, "这里会显示 candidate / renderer 的 canary / active / rollback 控制。"); - clearNode(els.opsProviderRuntimeMetrics, "这里会显示 provider runtime metrics 与 cost trend dashboard。"); + clearNode(dom.opsRuntimeIncidentSnapshot); + clearNode(dom.opsRuntimeReceipts); + clearNode(dom.opsProviderRouting); + clearNode(dom.opsProviderRollout); + clearNode(dom.opsProviderRuntimeMetrics); + clearNode(dom.opsStoryBootstrapWorldSummary); + clearNode(dom.opsStoryBootstrapWorldDetail); + if (!opsState.opsRuntimeIncidentSnapshot) { + clearNode(dom.opsRuntimeIncidentSnapshot, "这里会显示运行时事故快照、通道回退、预算拦截与缓存命中概况。"); + clearNode(dom.opsRuntimeReceipts, "这里会显示最近的运行回执。"); + clearNode(dom.opsProviderRouting, "这里会显示通道路由策略,以及候选链路和渲染链路的当前路由配置。"); + clearNode(dom.opsProviderRollout, "这里会显示候选链路和渲染链路的灰度、全量启用与回滚控制。"); + clearNode(dom.opsProviderRuntimeMetrics, "这里会显示通道运行指标、灰度阶段对比与成本趋势。"); + clearNode(dom.opsStoryBootstrapWorldSummary, "这里会显示 Story bootstrap 首轮质量门碰撞摘要。"); + clearNode(dom.opsStoryBootstrapWorldDetail, "这里会显示选中 world 的 Story bootstrap 重试明细。"); } else { - const snapshot = appState.opsRuntimeIncidentSnapshot; - els.opsRuntimeIncidentSnapshot.appendChild( + const snapshot = opsState.opsRuntimeIncidentSnapshot; + const qualitySummary = opsState.opsQualitySummary || {}; + const commercialization = opsState.opsCommercializationSummary || {}; + dom.opsRuntimeIncidentSnapshot.appendChild( createListCard({ - title: "Runtime Incident Snapshot", - score: `${snapshot.incident_count ?? 0} incidents`, + title: "运行时事故快照", + score: `${snapshot.incident_count ?? 0} 起事故`, body: - `health ${snapshot.health_status || "-"} · schema ${snapshot.schema_lifecycle_status || "-"}\n` + - `receipts ${snapshot.receipt_count ?? 0} · cache hit ${snapshot.cache_hit_rate !== null && snapshot.cache_hit_rate !== undefined ? Number(snapshot.cache_hit_rate).toFixed(3) : "-"} · cost ${Number(snapshot.total_estimated_cost || 0).toFixed(3)}\n` + - `latency runtime ${snapshot.latency_summary?.runtime?.avg_latency_ms !== null && snapshot.latency_summary?.runtime?.avg_latency_ms !== undefined ? Number(snapshot.latency_summary.runtime.avg_latency_ms).toFixed(1) : "-"}ms / p95 ${snapshot.latency_summary?.runtime?.p95_latency_ms !== null && snapshot.latency_summary?.runtime?.p95_latency_ms !== undefined ? Number(snapshot.latency_summary.runtime.p95_latency_ms).toFixed(1) : "-"}ms\n` + - `candidate ${snapshot.latency_summary?.candidate?.avg_latency_ms !== null && snapshot.latency_summary?.candidate?.avg_latency_ms !== undefined ? Number(snapshot.latency_summary.candidate.avg_latency_ms).toFixed(1) : "-"}ms · renderer ${snapshot.latency_summary?.renderer?.avg_latency_ms !== null && snapshot.latency_summary?.renderer?.avg_latency_ms !== undefined ? Number(snapshot.latency_summary.renderer.avg_latency_ms).toFixed(1) : "-"}ms\n` + - `incident type ${Object.entries(snapshot.by_incident_type || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `provider ${Object.entries(snapshot.by_provider || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `surface ${Object.entries(snapshot.by_surface || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n\n` + - `latest incidents:\n${(snapshot.latest_incidents || []).map((item) => `${item.action} · ${item.response_status} · ${(item.incident_flags || []).join("/") || "-"}\n${item.selected_provider || item.provider || "-"} · ${item.session_id || "-"} · ${item.world_version_id || "-"}\nlatency ${item.runtime_latency_ms !== null && item.runtime_latency_ms !== undefined ? Number(item.runtime_latency_ms).toFixed(1) : "-"}ms`).join("\n\n") || "-"}` + `健康状态 ${snapshot.health_status || "-"} · 数据库结构 ${snapshot.schema_lifecycle_status || "-"}\n` + + `回执数 ${snapshot.receipt_count ?? 0} · 缓存命中率 ${snapshot.cache_hit_rate !== null && snapshot.cache_hit_rate !== undefined ? Number(snapshot.cache_hit_rate).toFixed(3) : "-"} · 总成本 ${Number(snapshot.total_estimated_cost || 0).toFixed(3)}\n` + + `运行时延迟 ${snapshot.latency_summary?.runtime?.avg_latency_ms !== null && snapshot.latency_summary?.runtime?.avg_latency_ms !== undefined ? Number(snapshot.latency_summary.runtime.avg_latency_ms).toFixed(1) : "-"}ms / P95 ${snapshot.latency_summary?.runtime?.p95_latency_ms !== null && snapshot.latency_summary?.runtime?.p95_latency_ms !== undefined ? Number(snapshot.latency_summary.runtime.p95_latency_ms).toFixed(1) : "-"}ms\n` + + `候选链路 ${snapshot.latency_summary?.candidate?.avg_latency_ms !== null && snapshot.latency_summary?.candidate?.avg_latency_ms !== undefined ? Number(snapshot.latency_summary.candidate.avg_latency_ms).toFixed(1) : "-"}ms · 渲染链路 ${snapshot.latency_summary?.renderer?.avg_latency_ms !== null && snapshot.latency_summary?.renderer?.avg_latency_ms !== undefined ? Number(snapshot.latency_summary.renderer.avg_latency_ms).toFixed(1) : "-"}ms\n` + + `事故类型 ${Object.entries(snapshot.by_incident_type || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + + `通道分布 ${Object.entries(snapshot.by_provider || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + + `入口分布 ${Object.entries(snapshot.by_surface || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n\n` + + `最近事故:\n${(snapshot.latest_incidents || []).map((item) => `${item.action} · ${item.response_status} · ${(item.incident_flags || []).join("/") || "-"}\n${item.selected_provider || item.provider || "-"} · ${item.session_id || "-"} · ${item.world_version_id || "-"}\n延迟 ${item.runtime_latency_ms !== null && item.runtime_latency_ms !== undefined ? Number(item.runtime_latency_ms).toFixed(1) : "-"}ms`).join("\n\n") || "-"}` }) ); + if (qualitySummary.summary) { + dom.opsRuntimeIncidentSnapshot.appendChild( + createListCard({ + title: "Canonical 质量摘要", + score: `${qualitySummary.summary.open_review_case_count ?? 0} open`, + body: + `${opsFieldLine("事件数", qualitySummary.summary.event_count ?? 0)} · ${opsFieldLine("案例数", qualitySummary.summary.review_case_count ?? 0)}\n` + + `${opsFieldLine("blocked", qualitySummary.summary.blocked_event_count ?? 0)} · ${opsFieldLine("review_required", qualitySummary.summary.review_required_event_count ?? 0)}\n` + + `${opsFieldLine("反馈项", qualitySummary.summary.feedback_item_count ?? 0)} · ${opsFieldLine("重试信号", qualitySummary.summary.retry_signal_count ?? 0)}\n` + + `${opsPairsLine("状态", qualitySummary.summary.by_status || {}, opsStatusLabel)}\n` + + `${opsPairsLine("入口", qualitySummary.summary.by_source_surface || {})}` + }) + ); + } - if (!appState.opsRuntimeReceipts.length) { - clearNode(els.opsRuntimeReceipts, "这里会显示最近的 runtime receipts。"); + if (!opsState.opsRuntimeReceipts.length) { + clearNode(dom.opsRuntimeReceipts, "这里会显示最近的运行回执。"); } else { - appState.opsRuntimeReceipts.forEach((item) => { - const card = document.createElement("article"); - card.className = "list-card"; - card.innerHTML = ` -
-

${item.action || "-"}

- ${item.response_status || "-"} -
-

${formatTimestamp(item.occurred_at)}\n${item.surface || "-"} · provider ${item.selected_provider || item.provider || "-"}\nflags ${(item.incident_flags || []).join(" / ") || "-"}\nrollout candidate ${item.candidate_rollout_status || "-"}${item.candidate_canary_match === null || item.candidate_canary_match === undefined ? "" : ` (${item.candidate_canary_match ? "bucket" : "no bucket"})`} · renderer ${item.renderer_rollout_status || "-"}${item.renderer_canary_match === null || item.renderer_canary_match === undefined ? "" : ` (${item.renderer_canary_match ? "bucket" : "no bucket"})`}\ncache ${item.cache_hit === null || item.cache_hit === undefined ? "-" : item.cache_hit ? "hit" : "miss"} · budget ${item.budget_blocked ? "blocked" : "ok"} · fallback ${item.fallback_used ? "yes" : "no"}\nlatency ${item.runtime_latency_ms !== null && item.runtime_latency_ms !== undefined ? Number(item.runtime_latency_ms).toFixed(1) : "-"}ms · candidate ${item.candidate_latency_ms !== null && item.candidate_latency_ms !== undefined ? Number(item.candidate_latency_ms).toFixed(1) : "-"}ms · renderer ${item.renderer_latency_ms !== null && item.renderer_latency_ms !== undefined ? Number(item.renderer_latency_ms).toFixed(1) : "-"}ms\nattempts ${item.attempt_count ?? 0} · candidate ${item.candidate_attempt_count ?? 0} · renderer ${item.renderer_attempt_count ?? 0}\nrequest cost ${item.candidate_estimated_request_cost_usd !== null && item.candidate_estimated_request_cost_usd !== undefined ? Number(item.candidate_estimated_request_cost_usd).toFixed(4) : "-"} / ${item.renderer_estimated_request_cost_usd !== null && item.renderer_estimated_request_cost_usd !== undefined ? Number(item.renderer_estimated_request_cost_usd).toFixed(4) : "-"}\nerror ${item.backend_error || "-"}\ncandidates ${(item.candidate_counts?.raw ?? 0)}/${(item.candidate_counts?.legal ?? 0)} · output ${item.output_chars ?? 0} · cost ${Number(item.estimated_cost || 0).toFixed(3)}

- `; - els.opsRuntimeReceipts.appendChild(card); + opsState.opsRuntimeReceipts.forEach((item) => { + const card = createListCard({ + title: item.action || "运行时回执", + score: item.response_status || "-", + body: opsSections( + opsSection("基础信息", [ + opsFieldLine("发生时间", formatTimestamp(item.occurred_at)), + opsFieldLine("入口", item.surface || "-"), + opsFieldLine("通道", opsProviderLabel(item.selected_provider || item.provider || "-")), + opsFieldLine("事件标记", (item.incident_flags || []).join(" / ") || "-"), + ]), + opsSection("灰度与策略", [ + opsFieldLine("候选链路", opsRolloutSummary(item.candidate_rollout_status, item.candidate_canary_match)), + opsFieldLine("渲染链路", opsRolloutSummary(item.renderer_rollout_status, item.renderer_canary_match)), + ]), + opsSection("缓存与预算", [ + opsFieldLine("缓存命中", item.cache_hit === null || item.cache_hit === undefined ? "-" : opsBooleanLabel(item.cache_hit)), + opsFieldLine("预算拦截", opsBooleanLabel(item.budget_blocked)), + opsFieldLine("是否回退", opsBooleanLabel(item.fallback_used)), + ]), + opsSection("性能与成本", [ + opsFieldLine("运行时延迟", opsLatencyValue(item.runtime_latency_ms)), + opsFieldLine("候选延迟", opsLatencyValue(item.candidate_latency_ms)), + opsFieldLine("渲染延迟", opsLatencyValue(item.renderer_latency_ms)), + opsFieldLine("尝试次数", `${item.attempt_count ?? 0} / 候选 ${item.candidate_attempt_count ?? 0} / 渲染 ${item.renderer_attempt_count ?? 0}`), + opsFieldLine("请求成本", `${opsCostValue(item.candidate_estimated_request_cost_usd, 4)} / ${opsCostValue(item.renderer_estimated_request_cost_usd, 4)}`), + opsFieldLine("总成本", opsCostValue(item.estimated_cost, 3)), + ]), + opsSection("输出与错误", [ + opsFieldLine("候选数", `${item.candidate_counts?.raw ?? 0}/${item.candidate_counts?.legal ?? 0}`), + opsFieldLine("输出字符", item.output_chars ?? 0), + opsFieldLine("错误", item.backend_error || "-"), + ]) + ) + }); + dom.opsRuntimeReceipts.appendChild(card); + }); + } + (opsState.opsQualityEvents || []).forEach((item) => { + const card = createListCard({ + title: `质量事件 · ${item.trace_id || item.event_id}`, + score: opsStatusLabel(item.status || "-"), + body: opsSections( + opsSection("基础信息", [ + opsFieldLine("入口", item.source_surface || "-"), + opsFieldLine("账户", item.account_id || "-"), + opsFieldLine("世界版本", item.world_version_id || "-"), + opsFieldLine("会话", item.session_id || "-"), + opsFieldLine("总分", item.overall_score !== undefined && item.overall_score !== null ? Number(item.overall_score).toFixed(2) : "-"), + ]), + opsSection("规则与审阅", [ + opsFieldLine("原因", opsIssueCodeList(item.reason_codes || [])), + opsFieldLine("Review Case", item.review_case_id || "-"), + opsFieldLine("创建时间", formatTimestamp(item.created_at)), + ]) + ) }); + if (item.trace_id === opsState.opsSelectedQualityTraceId) { + card.classList.add("is-active"); + } + card.addEventListener("click", async () => { + await OpsActionsRuntime.loadOpsQualityTraceDetail(item.trace_id); + renderOpsSurface(["runtime"]); + }); + dom.opsRuntimeReceipts.appendChild(card); + }); + if (opsState.opsQualityTraceDetail) { + const detail = opsState.opsQualityTraceDetail; + dom.opsRuntimeReceipts.appendChild( + createListCard({ + title: `质量 Trace 详情 · ${detail.trace_id || "-"}`, + score: opsStatusLabel(detail.event?.status || "-"), + body: opsSections( + opsSection("事件", [ + opsFieldLine("入口", detail.event?.source_surface || "-"), + opsFieldLine("账户", detail.linked_context?.account_id || "-"), + opsFieldLine("世界", opsWorldLabel(detail.linked_context?.world_id || "-")), + opsFieldLine("世界版本", detail.linked_context?.world_version_id || "-"), + opsFieldLine("会话", detail.linked_context?.session_id || "-"), + ]), + opsSection("评分", [ + opsFieldLine("总分", detail.score?.overall_score !== undefined && detail.score?.overall_score !== null ? Number(detail.score.overall_score).toFixed(2) : "-"), + opsFieldLine("Veto", detail.score?.veto === undefined ? "-" : opsBooleanLabel(detail.score.veto)), + opsFieldLine("原因", opsIssueCodeList(detail.score?.reason_codes || detail.review_case?.reason_codes || [])), + ]), + opsSection("审阅", [ + opsFieldLine("Case", detail.review_case?.case_id || "-"), + opsFieldLine("状态", opsStatusLabel(detail.review_case?.status || "-")), + opsFieldLine("Owner", detail.review_case?.owner_id || "-"), + ]), + opsSection("反馈时间线", [ + opsFieldLine("反馈项", detail.feedback_summary?.feedback_item_count ?? 0), + opsFieldLine("重试信号", detail.feedback_summary?.retry_signal_count ?? 0), + (detail.feedback_items || []).map((item) => `${item.feedback_type || "-"} · ${item.signal || "-"} · ${formatTimestamp(item.created_at)}\n${Object.entries(item.payload || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}`).join("\n\n") || "-", + ]) + ) + }) + ); + } + if (commercialization.pilot_vs_paid) { + dom.opsRuntimeIncidentSnapshot.appendChild( + createListCard({ + title: "Commercialization Snapshot", + score: `${commercialization.pilot_vs_paid.pilot_account_count ?? 0}/${commercialization.pilot_vs_paid.paid_account_count ?? 0}`, + body: + `pilot ${commercialization.pilot_vs_paid.pilot_account_count ?? 0} · paid ${commercialization.pilot_vs_paid.paid_account_count ?? 0}\n` + + `renewal_due ${(commercialization.renewal_due_accounts || {}).count ?? 0} · churn_risk ${(commercialization.churn_risk_accounts || {}).count ?? 0}\n` + + `dunning ${(commercialization.dunning_runs || {}).count ?? 0} · pilot_ready ${(commercialization.pilot_conversion || {}).ready_count ?? 0} · expansion ${(commercialization.expansion_candidates || {}).recommended_count ?? 0}\n` + + `support backlog ${(commercialization.support_backlog || {}).count ?? 0} · dispute backlog ${(commercialization.dispute_backlog || {}).count ?? 0}`, + }) + ); + } + if (commercialization.launch_week_alert_pack?.summary) { + const pack = commercialization.launch_week_alert_pack; + dom.opsRuntimeIncidentSnapshot.appendChild( + createListCard({ + title: "Launch Week Alert Pack", + score: `${pack.summary.alert_count ?? 0} alerts`, + body: + `severity ${(pack.summary.by_severity && Object.entries(pack.summary.by_severity).map(([key, value]) => `${key}=${value}`).join(" / ")) || "-"}\n` + + `owners ${(pack.summary.by_owner_role && Object.entries(pack.summary.by_owner_role).map(([key, value]) => `${key}=${value}`).join(" / ")) || "-"}\n` + + `${(pack.alerts || []).map((item) => `${item.alert_key} · ${item.severity} · ${item.owner_role}\ncount ${item.count ?? 0}\n${item.summary}\nrefs ${(item.drilldown_refs || []).map((ref) => `${ref.kind}:${ref.id}`).join(" / ") || "-"}`).join("\n\n") || "-"}` + }) + ); } } - if (!appState.opsProviderRouting) { - clearNode(els.opsProviderRouting, "这里会显示 candidate / renderer 当前的 routing policy。"); + if (!opsState.opsProviderRouting) { + clearNode(dom.opsProviderRouting, "这里会显示通道路由策略,以及候选链路和渲染链路的当前路由配置。"); } else { - const policy = appState.opsProviderRouting; - els.opsProviderRouting.appendChild( + const policy = opsState.opsProviderRouting; + dom.opsProviderRouting.appendChild( createListCard({ - title: "Provider Routing Policy", - score: `${policy.candidate?.backend_present ? "candidate:on" : "candidate:off"} · ${policy.renderer?.backend_present ? "renderer:on" : "renderer:off"}`, + title: "通道路由策略", + score: `${policy.candidate?.backend_present ? "候选链路已启用" : "候选链路已关闭"} · ${policy.renderer?.backend_present ? "渲染链路已启用" : "渲染链路已关闭"}`, body: - `candidate providers ${(policy.candidate?.provider_order || []).join(" / ") || "-"}\n` + - `candidate retry ${policy.candidate?.retry_policy?.max_attempts ?? "-"} · cache ${policy.candidate?.cache_policy?.enabled ? `on:${policy.candidate?.cache_policy?.max_entries}` : "off"} · budget ${policy.candidate?.budget_policy?.max_prompt_chars ?? "-"}/${policy.candidate?.budget_policy?.max_estimated_cost_usd ?? "-"}\n` + - `candidate fallback ${(policy.candidate?.fallback_chain || []).join(" -> ") || "-"}\n\n` + - `renderer providers ${(policy.renderer?.provider_order || []).join(" / ") || "-"}\n` + - `renderer retry ${policy.renderer?.retry_policy?.max_attempts ?? "-"} · cache ${policy.renderer?.cache_policy?.enabled ? `on:${policy.renderer?.cache_policy?.max_entries}` : "off"} · budget ${policy.renderer?.budget_policy?.max_prompt_chars ?? "-"}/${policy.renderer?.budget_policy?.max_estimated_cost_usd ?? "-"}\n` + - `renderer fallback ${(policy.renderer?.fallback_chain || []).join(" -> ") || "-"}` + `候选链路通道 ${(policy.candidate?.provider_order || []).join(" / ") || "-"}\n` + + `候选链路重试 ${policy.candidate?.retry_policy?.max_attempts ?? "-"} · 缓存 ${policy.candidate?.cache_policy?.enabled ? `启用:${policy.candidate?.cache_policy?.max_entries}` : "关闭"} · 预算 ${policy.candidate?.budget_policy?.max_prompt_chars ?? "-"}/${policy.candidate?.budget_policy?.max_estimated_cost_usd ?? "-"}\n` + + `候选链路回退 ${(policy.candidate?.fallback_chain || []).join(" -> ") || "-"}\n\n` + + `渲染链路通道 ${(policy.renderer?.provider_order || []).join(" / ") || "-"}\n` + + `渲染链路重试 ${policy.renderer?.retry_policy?.max_attempts ?? "-"} · 缓存 ${policy.renderer?.cache_policy?.enabled ? `启用:${policy.renderer?.cache_policy?.max_entries}` : "关闭"} · 预算 ${policy.renderer?.budget_policy?.max_prompt_chars ?? "-"}/${policy.renderer?.budget_policy?.max_estimated_cost_usd ?? "-"}\n` + + `渲染链路回退 ${(policy.renderer?.fallback_chain || []).join(" -> ") || "-"}` }) ); } - if (!appState.opsProviderRollout) { - clearNode(els.opsProviderRollout, "这里会显示 candidate / renderer 的 canary / active / rollback 控制。"); + if (!opsState.opsProviderRollout) { + clearNode(dom.opsProviderRollout, "这里会显示候选链路和渲染链路的灰度、全量启用与回滚控制。"); } else { - const rollout = appState.opsProviderRollout; + const rollout = opsState.opsProviderRollout; const candidate = rollout.tracks?.candidate || {}; const renderer = rollout.tracks?.renderer || {}; - els.opsProviderRollout.appendChild( + dom.opsProviderRollout.appendChild( createListCard({ - title: "Provider Rollout Summary", + title: "通道灰度摘要", score: rollout.recommended_next_action || "-", body: - `active ${(rollout.active_tracks || []).join(" / ") || "-"} · canary ${(rollout.canary_tracks || []).join(" / ") || "-"} · rolled_back ${(rollout.rolled_back_tracks || []).join(" / ") || "-"}\n` + - `candidate ${candidate.rollout_status || "-"} · bucket ${candidate.bucket_percentage ?? 0}% · allowlist ${(candidate.world_allowlist || []).join(" / ") || "-"}\n` + - `renderer ${renderer.rollout_status || "-"} · bucket ${renderer.bucket_percentage ?? 0}% · allowlist ${(renderer.world_allowlist || []).join(" / ") || "-"}` + `全量启用 ${(rollout.active_tracks || []).join(" / ") || "-"} · 灰度中 ${(rollout.canary_tracks || []).join(" / ") || "-"} · 已回滚 ${(rollout.rolled_back_tracks || []).join(" / ") || "-"}\n` + + `候选链路 ${candidate.rollout_status || "-"} · 分桶 ${candidate.bucket_percentage ?? 0}% · 白名单 ${(candidate.world_allowlist || []).join(" / ") || "-"}\n` + + `渲染链路 ${renderer.rollout_status || "-"} · 分桶 ${renderer.bucket_percentage ?? 0}% · 白名单 ${(renderer.world_allowlist || []).join(" / ") || "-"}` }) ); } - if (!appState.opsProviderRuntimeMetrics) { - clearNode(els.opsProviderRuntimeMetrics, "这里会显示 provider runtime metrics 与 cost trend dashboard。"); - } else { - const metrics = appState.opsProviderRuntimeMetrics; + if (!opsState.opsProviderRuntimeMetrics) { + clearNode(dom.opsProviderRuntimeMetrics, "这里会显示通道运行指标与成本趋势看板。"); + } else { + const metrics = opsState.opsProviderRuntimeMetrics; const rolloutStageCard = createListCard({ - title: "Rollout Stage Comparison", - score: "shadow / canary / active", - body: - `candidate:\n${(metrics.rollout_stage_summary?.candidate || []).map((item) => `${item.rollout_status}\nreceipts ${item.receipt_count} · incident ${Number(item.incident_rate || 0).toFixed(3)} · fallback ${Number(item.fallback_rate || 0).toFixed(3)} · backend err ${Number(item.backend_error_rate || 0).toFixed(3)}\ncost ${Number(item.total_estimated_cost || 0).toFixed(3)} · avg ${Number(item.avg_estimated_cost || 0).toFixed(3)}\nlatency ${item.runtime_latency?.avg_latency_ms !== null && item.runtime_latency?.avg_latency_ms !== undefined ? Number(item.runtime_latency.avg_latency_ms).toFixed(1) : "-"}ms / p95 ${item.runtime_latency?.p95_latency_ms !== null && item.runtime_latency?.p95_latency_ms !== undefined ? Number(item.runtime_latency.p95_latency_ms).toFixed(1) : "-"}ms · candidate ${item.track_latency?.avg_latency_ms !== null && item.track_latency?.avg_latency_ms !== undefined ? Number(item.track_latency.avg_latency_ms).toFixed(1) : "-"}ms\ncanary hits ${item.canary_match_count ?? 0}`).join("\n\n") || "-"}\n\n` + - `renderer:\n${(metrics.rollout_stage_summary?.renderer || []).map((item) => `${item.rollout_status}\nreceipts ${item.receipt_count} · incident ${Number(item.incident_rate || 0).toFixed(3)} · fallback ${Number(item.fallback_rate || 0).toFixed(3)} · backend err ${Number(item.backend_error_rate || 0).toFixed(3)}\ncost ${Number(item.total_estimated_cost || 0).toFixed(3)} · avg ${Number(item.avg_estimated_cost || 0).toFixed(3)}\nlatency ${item.runtime_latency?.avg_latency_ms !== null && item.runtime_latency?.avg_latency_ms !== undefined ? Number(item.runtime_latency.avg_latency_ms).toFixed(1) : "-"}ms / p95 ${item.runtime_latency?.p95_latency_ms !== null && item.runtime_latency?.p95_latency_ms !== undefined ? Number(item.runtime_latency.p95_latency_ms).toFixed(1) : "-"}ms · renderer ${item.track_latency?.avg_latency_ms !== null && item.track_latency?.avg_latency_ms !== undefined ? Number(item.track_latency.avg_latency_ms).toFixed(1) : "-"}ms\ncanary hits ${item.canary_match_count ?? 0}`).join("\n\n") || "-"}` + title: "灰度阶段对比", + score: "影子 / 灰度 / 全量", + body: opsSections( + opsSection("候选链路", [ + opsParagraphList(metrics.rollout_stage_summary?.candidate || [], (item) => opsStageMetricParagraph("candidate", item)), + ]), + opsSection("渲染链路", [ + opsParagraphList(metrics.rollout_stage_summary?.renderer || [], (item) => opsStageMetricParagraph("renderer", item)), + ]) + ) }); - els.opsProviderRuntimeMetrics.appendChild(rolloutStageCard); - els.opsProviderRuntimeMetrics.appendChild( + dom.opsProviderRuntimeMetrics.appendChild(rolloutStageCard); + dom.opsProviderRuntimeMetrics.appendChild( createListCard({ - title: "Provider Runtime Metrics", - score: `${metrics.receipt_count ?? 0} receipts`, - body: - `total cost ${Number(metrics.total_estimated_cost || 0).toFixed(3)}\n` + - `latency runtime ${metrics.latency_summary?.runtime?.avg_latency_ms !== null && metrics.latency_summary?.runtime?.avg_latency_ms !== undefined ? Number(metrics.latency_summary.runtime.avg_latency_ms).toFixed(1) : "-"}ms / p95 ${metrics.latency_summary?.runtime?.p95_latency_ms !== null && metrics.latency_summary?.runtime?.p95_latency_ms !== undefined ? Number(metrics.latency_summary.runtime.p95_latency_ms).toFixed(1) : "-"}ms\n` + - `candidate ${metrics.latency_summary?.candidate?.avg_latency_ms !== null && metrics.latency_summary?.candidate?.avg_latency_ms !== undefined ? Number(metrics.latency_summary.candidate.avg_latency_ms).toFixed(1) : "-"}ms · renderer ${metrics.latency_summary?.renderer?.avg_latency_ms !== null && metrics.latency_summary?.renderer?.avg_latency_ms !== undefined ? Number(metrics.latency_summary.renderer.avg_latency_ms).toFixed(1) : "-"}ms\n` + - `surface ${Object.entries(metrics.surface_summary || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `action ${Object.entries(metrics.action_summary || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n\n` + - `providers:\n${(metrics.provider_summary || []).map((item) => `${item.provider}\nreceipts ${item.receipt_count} · incidents ${item.incident_count} · candidate ${item.selected_as_candidate_count ?? 0} · renderer ${item.selected_as_renderer_count ?? 0}\nfallback ${Number(item.fallback_rate || 0).toFixed(3)} · budget ${Number(item.budget_block_rate || 0).toFixed(3)} · backend err ${Number(item.backend_error_rate || 0).toFixed(3)} · cache ${item.cache_hit_rate === null || item.cache_hit_rate === undefined ? "-" : Number(item.cache_hit_rate).toFixed(3)}\nlatency ${item.avg_runtime_latency_ms !== null && item.avg_runtime_latency_ms !== undefined ? Number(item.avg_runtime_latency_ms).toFixed(1) : "-"}ms / p95 ${item.p95_runtime_latency_ms !== null && item.p95_runtime_latency_ms !== undefined ? Number(item.p95_runtime_latency_ms).toFixed(1) : "-"}ms\ncandidate ${item.avg_candidate_latency_ms !== null && item.avg_candidate_latency_ms !== undefined ? Number(item.avg_candidate_latency_ms).toFixed(1) : "-"}ms · renderer ${item.avg_renderer_latency_ms !== null && item.avg_renderer_latency_ms !== undefined ? Number(item.avg_renderer_latency_ms).toFixed(1) : "-"}ms\ncost ${Number(item.total_estimated_cost || 0).toFixed(3)} · avg ${Number(item.avg_estimated_cost || 0).toFixed(3)} · req ${Number(item.candidate_estimated_request_cost || 0).toFixed(4)}/${Number(item.renderer_estimated_request_cost || 0).toFixed(4)} · chars ${Number(item.avg_output_chars || 0).toFixed(1)}`).join("\n\n") || "-" }\n\n` + - `cost trend:\n${(metrics.cost_trend || []).map((item) => `${item.bucket} · cost ${Number(item.total_estimated_cost || 0).toFixed(3)} · receipts ${item.receipt_count} · incidents ${item.incident_count}`).join("\n") || "-"}\n\n` + - `latency trend:\n${(metrics.latency_trend || []).map((item) => `${item.bucket} · runtime ${item.runtime?.avg_latency_ms !== null && item.runtime?.avg_latency_ms !== undefined ? Number(item.runtime.avg_latency_ms).toFixed(1) : "-"}ms · candidate ${item.candidate?.avg_latency_ms !== null && item.candidate?.avg_latency_ms !== undefined ? Number(item.candidate.avg_latency_ms).toFixed(1) : "-"}ms · renderer ${item.renderer?.avg_latency_ms !== null && item.renderer?.avg_latency_ms !== undefined ? Number(item.renderer.avg_latency_ms).toFixed(1) : "-"}ms`).join("\n") || "-"}` - }) - ); - } + title: "通道运行指标", + score: `${metrics.receipt_count ?? 0} 条回执`, + body: opsSections( + opsSection("总览", [ + opsFieldLine("总成本", opsCostValue(metrics.total_estimated_cost, 3)), + opsFieldLine("运行时延迟", opsLatencyValue(metrics.latency_summary?.runtime?.avg_latency_ms)), + opsFieldLine("运行时 P95", opsLatencyValue(metrics.latency_summary?.runtime?.p95_latency_ms)), + opsFieldLine("候选延迟", opsLatencyValue(metrics.latency_summary?.candidate?.avg_latency_ms)), + opsFieldLine("渲染延迟", opsLatencyValue(metrics.latency_summary?.renderer?.avg_latency_ms)), + opsPairsLine("入口分布", metrics.surface_summary || {}), + opsPairsLine("动作分布", metrics.action_summary || {}), + ]), + opsSection("通道明细", [ + opsParagraphList(metrics.provider_summary || [], (item) => opsProviderMetricParagraph(item)), + ]), + opsSection("成本趋势", [ + opsParagraphList(metrics.cost_trend || [], (item) => opsCostTrendParagraph(item)), + ]), + opsSection("延迟趋势", [ + opsParagraphList(metrics.latency_trend || [], (item) => opsLatencyTrendParagraph(item)), + ]) + ) + }) + ); + } + + if (!(opsState.opsStoryBootstrapWorldSummary || []).length) { + clearNode(dom.opsStoryBootstrapWorldSummary, "这里会显示 Story bootstrap 首轮质量门碰撞摘要。"); + } else { + const bootstrapWorlds = opsState.opsStoryBootstrapWorldSummary || []; + const attemptedCount = bootstrapWorlds.reduce((sum, item) => sum + Number(item.attemptedCount || 0), 0); + const firstFailed = bootstrapWorlds.reduce((sum, item) => sum + Number(item.firstAttemptQualityGuardFailedCount || 0), 0); + const finalFailed = bootstrapWorlds.reduce((sum, item) => sum + Number(item.finalQualityGuardFailedCount || 0), 0); + dom.opsStoryBootstrapWorldSummary.appendChild( + createListCard({ + title: "Story Bootstrap 质量门碰撞摘要", + score: `${bootstrapWorlds.length} 个 worlds`, + body: + `${opsFieldLine("总尝试", attemptedCount)} · ${opsFieldLine("首轮失败", firstFailed)} · ${opsFieldLine("最终失败", finalFailed)}\n` + + `${opsFieldLine("首轮失败率", formatPercent(firstFailed / Math.max(1, attemptedCount)))} · ${opsFieldLine("最终失败率", formatPercent(finalFailed / Math.max(1, attemptedCount)))}\n` + + `${opsFieldLine("当前 drill-down", opsWorldLabel(opsState.selectedOpsWorldId || bootstrapWorlds[0]?.worldId || "-"))}` + }) + ); + bootstrapWorlds.forEach((item) => { + const card = createListCard({ + title: `Bootstrap · ${opsWorldLabel(item.worldId)}`, + score: `${Number(item.qualityGuardCollisionDelta || 0).toFixed(3)} Δ`, + body: + `${opsFieldLine("总尝试", item.attemptedCount ?? 0)} · ${opsFieldLine("首轮失败", item.firstAttemptQualityGuardFailedCount ?? 0)} · ${opsFieldLine("最终失败", item.finalQualityGuardFailedCount ?? 0)}\n` + + `${opsFieldLine("首轮失败率", formatPercent(item.firstAttemptQualityGuardFailedRate || 0))} · ${opsFieldLine("最终失败率", formatPercent(item.finalQualityGuardFailedRate || 0))}\n` + + `${opsFieldLine("重试恢复率", formatPercent(item.retriedRecoveryRate || 0))} · ${opsFieldLine("碰撞降幅", Number(item.qualityGuardCollisionDelta || 0).toFixed(3))}` + }); + if (item.worldId === opsState.selectedOpsWorldId) { + card.classList.add("is-active"); + } + card.addEventListener("click", async () => { + opsState.selectedOpsWorldId = item.worldId; + syncOpsNavigationContext({ world_id: item.worldId }, { preserveExisting: true }); + await refreshOpsSurface({ scopes: ["runtime", "navigation"] }); + }); + dom.opsStoryBootstrapWorldSummary.appendChild(card); + }); + } - clearNode(els.opsMeterList); - if (!appState.opsMeters.length) { - clearNode(els.opsMeterList, "继续阅读发生后,这里会出现 meter 记录。"); + if (!opsState.opsStoryBootstrapWorldDetail?.world) { + clearNode(dom.opsStoryBootstrapWorldDetail, "这里会显示选中 world 的 Story bootstrap 重试明细。"); + } else { + const detail = opsState.opsStoryBootstrapWorldDetail; + dom.opsStoryBootstrapWorldDetail.appendChild( + createListCard({ + title: `Bootstrap Drill-Down · ${opsWorldLabel(detail.world.worldId)}`, + score: `${detail.rows?.length ?? 0} 条`, + body: + `${opsFieldLine("总尝试", detail.world.attemptedCount ?? 0)} · ${opsFieldLine("首轮失败率", formatPercent(detail.world.firstAttemptQualityGuardFailedRate || 0))} · ${opsFieldLine("最终失败率", formatPercent(detail.world.finalQualityGuardFailedRate || 0))}\n` + + `${opsFieldLine("重试恢复率", formatPercent(detail.world.retriedRecoveryRate || 0))} · ${opsFieldLine("碰撞降幅", Number(detail.world.qualityGuardCollisionDelta || 0).toFixed(3))}\n\n` + + `最近结果:\n${(detail.rows || []).map((item) => + `${item.sessionId || "-"} · ${item.worldVersionId || "-"}\n` + + `attempts ${item.attemptCount ?? 0} · first ${item.firstAttemptResultStatus || "-"} · final ${item.finalResultStatus || "-"}\n` + + `recovered ${item.recoveredAfterRetry ? "yes" : "no"} · intent ${item.bootstrapIntent || "-"} · ${formatTimestamp(item.occurredAt)}` + ).join("\n\n") || "-"}` + }) + ); + } + + clearNode(dom.opsMeterList); + if (!opsState.opsMeters.length) { + clearNode(dom.opsMeterList, "继续阅读发生后,这里会出现 meter 记录。"); } else { - appState.opsMeters.forEach((meter) => { + opsState.opsMeters.forEach((meter) => { const card = document.createElement("article"); card.className = "list-card"; card.innerHTML = ` @@ -608,195 +1309,233 @@ function renderOpsRuntimeSection() {

${meter.world_version_id || "-"}\n${meter.session_id || "-"}\nunits ${Number(meter.usage_units || 0).toFixed(3)} · wallet ${meter.wallet_type || "-"}\ntier ${meter.subscription_tier || "-"} · rule ${meter.model_policy_version || "-"}

`; - els.opsMeterList.appendChild(card); + dom.opsMeterList.appendChild(card); }); } } function renderOpsJobsSection() { - clearNode(els.opsAsyncJobSummary); - clearNode(els.opsAsyncJobBootReconcile); - clearNode(els.opsAsyncJobIncidents); - clearNode(els.opsAsyncJobArtifactRetention); - clearNode(els.opsAsyncJobOperatorHistory); - clearNode(els.opsAsyncJobHandoffBundle); - clearNode(els.opsAsyncJobAdapterValidation); - clearNode(els.opsAsyncJobAdapterHealthProbe); - clearNode(els.opsAsyncJobNotificationReceipts); - clearNode(els.opsAsyncNotificationRetryQueue); - clearNode(els.opsAsyncNotificationDeadLetterQueue); - clearNode(els.opsAsyncRetryOutcomeDashboard); - clearNode(els.opsAsyncJobs); - if (!appState.opsAsyncJobSummary) { - clearNode(els.opsAsyncJobSummary, "这里会显示 long-running jobs 的队列摘要。"); - clearNode(els.opsAsyncJobBootReconcile, "这里会显示 boot-time async reconciler 的处理结果。"); - clearNode(els.opsAsyncJobIncidents, "这里会显示 failed / queued / stale running jobs 的 incident recovery 摘要。"); - clearNode(els.opsAsyncJobArtifactRetention, "这里会显示 async job artifact retention 与保留状态。"); - clearNode(els.opsAsyncJobOperatorHistory, "这里会显示 operator run history。"); - clearNode(els.opsAsyncJobHandoffBundle, "这里会显示 async job handoff bundle 与 acknowledgement 摘要。"); - clearNode(els.opsAsyncJobAdapterValidation, "这里会显示 async adapter config validation。"); - clearNode(els.opsAsyncJobAdapterHealthProbe, "这里会显示 async adapter health probe。"); - clearNode(els.opsAsyncJobNotificationReceipts, "这里会显示 notification delivery receipts。"); - clearNode(els.opsAsyncNotificationRetryQueue, "这里会显示 notification retry queue。"); - clearNode(els.opsAsyncNotificationDeadLetterQueue, "这里会显示 notification dead-letter queue。"); - clearNode(els.opsAsyncRetryOutcomeDashboard, "这里会显示 retry outcome dashboard。"); - clearNode(els.opsAsyncJobs, "这里会显示 learned training / runtime backup 的异步工作流状态。"); + clearNode(dom.opsAsyncJobSummary); + clearNode(dom.opsAsyncJobBootReconcile); + clearNode(dom.opsAsyncJobIncidents); + clearNode(dom.opsAsyncJobArtifactRetention); + clearNode(dom.opsAsyncJobOperatorHistory); + clearNode(dom.opsAsyncJobHandoffBundle); + clearNode(dom.opsAsyncJobAdapterValidation); + clearNode(dom.opsAsyncJobAdapterHealthProbe); + clearNode(dom.opsAsyncJobNotificationReceipts); + clearNode(dom.opsAsyncNotificationRetryQueue); + clearNode(dom.opsAsyncNotificationDeadLetterQueue); + clearNode(dom.opsAsyncRetryOutcomeDashboard); + clearNode(dom.opsAsyncJobs); + if (!opsState.opsAsyncJobSummary) { + clearNode(dom.opsAsyncJobSummary, "这里会显示长任务队列摘要。"); + clearNode(dom.opsAsyncJobBootReconcile, "这里会显示启动时异步对账器的处理结果。"); + clearNode(dom.opsAsyncJobIncidents, "这里会显示失败、排队中和长时间运行任务的事故恢复摘要。"); + clearNode(dom.opsAsyncJobArtifactRetention, "这里会显示异步任务产物保留与保留状态。"); + clearNode(dom.opsAsyncJobOperatorHistory, "这里会显示运营操作历史。"); + clearNode(dom.opsAsyncJobHandoffBundle, "这里会显示异步任务交接包与确认摘要。"); + clearNode(dom.opsAsyncJobAdapterValidation, "这里会显示异步适配器配置校验结果。"); + clearNode(dom.opsAsyncJobAdapterHealthProbe, "这里会显示异步适配器健康探测结果。"); + clearNode(dom.opsAsyncJobNotificationReceipts, "这里会显示通知送达回执。"); + clearNode(dom.opsAsyncNotificationRetryQueue, "这里会显示通知重试队列。"); + clearNode(dom.opsAsyncNotificationDeadLetterQueue, "这里会显示通知死信队列。"); + clearNode(dom.opsAsyncRetryOutcomeDashboard, "这里会显示重试结果看板。"); + clearNode(dom.opsAsyncJobs, "这里会显示学习层训练和运行时备份相关的异步工作流状态。"); } else { - const summary = appState.opsAsyncJobSummary; - els.opsAsyncJobSummary.appendChild( + const summary = opsState.opsAsyncJobSummary; + dom.opsAsyncJobSummary.appendChild( createListCard({ - title: "Async Job Summary", - score: `${summary.job_count ?? 0} jobs`, - body: - `status ${Object.entries(summary.by_status || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `type ${Object.entries(summary.by_type || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `supported ${(summary.supported_job_types || []).join(" / ") || "-"}\n` + - `lease ${Object.entries(summary.by_lease_status || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `latest finished ${summary.latest_finished_job?.job_id || "-"} · ${summary.latest_finished_job?.status || "-"}` + title: "异步任务摘要", + score: `${summary.job_count ?? 0} 个任务`, + body: opsSections( + opsSection("总体分布", [ + opsPairsLine("状态分布", summary.by_status || {}, opsStatusLabel), + opsPairsLine("任务类型分布", summary.by_type || {}), + opsFieldLine("支持的任务类型", (summary.supported_job_types || []).join(" / ") || "-"), + opsPairsLine("租约状态", summary.by_lease_status || {}, opsStatusLabel), + ]), + opsSection("最近完成", [ + opsFieldLine("任务 ID", summary.latest_finished_job?.job_id || "-"), + opsFieldLine("状态", opsStatusLabel(summary.latest_finished_job?.status || "-")), + ]) + ) }) ); - const boot = appState.opsAsyncJobBootReconcile || {}; - els.opsAsyncJobBootReconcile.appendChild( + const boot = opsState.opsAsyncJobBootReconcile || {}; + dom.opsAsyncJobBootReconcile.appendChild( createListCard({ - title: "Boot-time Async Reconciler", - score: `${boot.reconciled_count ?? 0} reconciled`, - body: - `requested by ${boot.requested_by || "-"}\n` + - `recommended ${boot.recommended_action || "-"}\n` + - `jobs ${(boot.reconciled_jobs || []).map((item) => `${item.job_id}:${item.last_recovery_action || "-"}`).join(" / ") || "-"}` + title: "启动时异步对账器", + score: `${boot.reconciled_count ?? 0} 个已对账`, + body: opsSections( + opsSection("基本信息", [ + opsFieldLine("发起人", boot.requested_by || "-"), + opsFieldLine("建议动作", boot.recommended_action || "-"), + ]), + opsSection("已处理任务", [ + opsFieldLine("任务列表", (boot.reconciled_jobs || []).map((item) => `${item.job_id}:${item.last_recovery_action || "-"}`).join(" / ") || "-"), + ]) + ) }) ); - const incidents = appState.opsAsyncJobIncidents || {}; - els.opsAsyncJobIncidents.appendChild( + const incidents = opsState.opsAsyncJobIncidents || {}; + dom.opsAsyncJobIncidents.appendChild( createListCard({ - title: "Async Job Incident Recovery", - score: incidents.status || "-", - body: - `recommended ${incidents.recommended_action || "-"}\n` + - `failed ${incidents.failed_count ?? 0} · queued ${incidents.queued_count ?? 0} · stale ${incidents.stale_running_count ?? 0} · expired lease ${incidents.expired_lease_count ?? 0} · recoverable ${incidents.recoverable_count ?? 0}\n` + - `by type ${Object.entries(incidents.by_type || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `failed jobs ${(incidents.failed_jobs || []).map((item) => item.job_id).join(" / ") || "-"}\n` + - `stale jobs ${(incidents.stale_running_jobs || []).map((item) => item.job_id).join(" / ") || "-"}` + title: "异步任务事故恢复", + score: opsStatusLabel(incidents.status || "-"), + body: opsSections( + opsSection("总体判断", [ + opsFieldLine("建议动作", incidents.recommended_action || "-"), + opsFieldLine("失败任务", incidents.failed_count ?? 0), + opsFieldLine("排队中任务", incidents.queued_count ?? 0), + opsFieldLine("长时间运行任务", incidents.stale_running_count ?? 0), + opsFieldLine("租约过期任务", incidents.expired_lease_count ?? 0), + opsFieldLine("可恢复任务", incidents.recoverable_count ?? 0), + ]), + opsSection("任务类型分布", [ + opsPairsLine("类型", incidents.by_type || {}), + ]), + opsSection("重点任务", [ + opsFieldLine("失败任务列表", (incidents.failed_jobs || []).map((item) => item.job_id).join(" / ") || "-"), + opsFieldLine("长时间运行任务列表", (incidents.stale_running_jobs || []).map((item) => item.job_id).join(" / ") || "-"), + ]) + ) }) ); - const retention = appState.opsAsyncJobArtifactRetention || {}; - const remoteShipping = appState.opsAsyncJobRemoteShipping || {}; - els.opsAsyncJobArtifactRetention.appendChild( + const retention = opsState.opsAsyncJobArtifactRetention || {}; + const remoteShipping = opsState.opsAsyncJobRemoteShipping || {}; + dom.opsAsyncJobArtifactRetention.appendChild( createListCard({ - title: "Async Job Artifact Retention", - score: `${retention.jobs_with_artifacts ?? 0} jobs`, - body: - `artifact count ${retention.total_artifact_count ?? 0} · bytes ${retention.total_bytes ?? 0}\n` + - `remote adapter ${(remoteShipping.registry?.default_adapter || "-")} · ${(remoteShipping.registry?.available_adapters || []).join(" / ") || "-"}\n` + - `status ${Object.entries(retention.by_status || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `expiring soon ${retention.expiring_soon_count ?? 0} · expired ${retention.expired_count ?? 0} · missing ${retention.missing_count ?? 0}\n` + - `remote ${Object.entries(remoteShipping.by_status || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `jobs ${(retention.artifact_jobs || []).map((item) => `${item.job_id}:${item.artifact_status}`).join(" / ") || "-"}` + title: "异步任务产物保留", + score: `${retention.jobs_with_artifacts ?? 0} 个任务`, + body: opsSections( + opsSection("总体情况", [ + opsFieldLine("产物总数", retention.total_artifact_count ?? 0), + opsFieldLine("总字节数", retention.total_bytes ?? 0), + opsFieldLine("默认远端适配器", remoteShipping.registry?.default_adapter || "-"), + opsFieldLine("可用远端适配器", (remoteShipping.registry?.available_adapters || []).join(" / ") || "-"), + ]), + opsSection("保留状态", [ + opsPairsLine("产物状态", retention.by_status || {}, opsStatusLabel), + opsFieldLine("即将过期", retention.expiring_soon_count ?? 0), + opsFieldLine("已过期", retention.expired_count ?? 0), + opsFieldLine("缺失", retention.missing_count ?? 0), + opsPairsLine("远端状态", remoteShipping.by_status || {}, opsStatusLabel), + ]), + opsSection("任务列表", [ + opsFieldLine("任务", (retention.artifact_jobs || []).map((item) => `${item.job_id}:${opsStatusLabel(item.artifact_status)}`).join(" / ") || "-"), + ]) + ) }) ); - const operatorHistory = appState.opsAsyncJobOperatorHistory || {}; - els.opsAsyncJobOperatorHistory.appendChild( + const operatorHistory = opsState.opsAsyncJobOperatorHistory || {}; + dom.opsAsyncJobOperatorHistory.appendChild( createListCard({ - title: "Operator Run History", - score: `${operatorHistory.entry_count ?? 0} entries`, - body: - `operators ${Object.entries(operatorHistory.by_operator || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `actions ${Object.entries(operatorHistory.by_action || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `latest ${(operatorHistory.latest_entries || []).map((item) => `${item.operator_id || "-"}:${item.action}@${item.job_id}`).join(" / ") || "-"}` + title: "运营操作历史", + score: `${operatorHistory.entry_count ?? 0} 条记录`, + body: opsSections( + opsSection("分布", [ + opsPairsLine("操作人", operatorHistory.by_operator || {}), + opsPairsLine("动作", operatorHistory.by_action || {}), + ]), + opsSection("最近记录", [ + opsFieldLine("最近操作", (operatorHistory.latest_entries || []).map((item) => `${item.operator_id || "-"}:${item.action}@${item.job_id}`).join(" / ") || "-"), + ]) + ) }) ); - const handoff = appState.opsAsyncJobHandoffBundle || {}; + const handoff = opsState.opsAsyncJobHandoffBundle || {}; const bundle = handoff.handoff_bundle || handoff; - const handoffSla = appState.opsAsyncJobHandoffSla || {}; - els.opsAsyncJobHandoffBundle.appendChild( + const handoffSla = opsState.opsAsyncJobHandoffSla || {}; + dom.opsAsyncJobHandoffBundle.appendChild( createListCard({ - title: "Async Job Handoff Bundle", - score: `${bundle.acknowledgement_summary?.pending_count ?? 0} pending`, + title: "异步任务交接包", + score: `${bundle.acknowledgement_summary?.pending_count ?? 0} 条待确认`, body: - `recommended ${bundle.recommended_next_action || "-"}\n` + - `sink ${(bundle.notification_sinks?.default_sink || "-")} · ${(bundle.notification_sinks?.available_sinks || []).join(" / ") || "-"}\n` + - `required ${bundle.acknowledgement_summary?.required_count ?? 0} · pending ${bundle.acknowledgement_summary?.pending_count ?? 0} · ack ${bundle.acknowledgement_summary?.acknowledged_count ?? 0}\n` + - `sla overdue ${handoffSla.overdue_count ?? 0} · pending ${handoffSla.pending_count ?? 0}\n` + - `jobs ${(bundle.jobs_requiring_handoff || []).map((item) => `${item.job_id}:${item.acknowledgement_status}/${item.handoff_sla_status || "-"}/${item.remote_shipping_status || "-"}`).join(" / ") || "-"}\n` + - `export ${handoff.export_path || "-"} · notify ${handoff.notification_receipt?.sink_name || "-"}` + `推荐动作 ${bundle.recommended_next_action || "-"}\n` + + `通知接收端 ${(bundle.notification_sinks?.default_sink || "-")} · ${(bundle.notification_sinks?.available_sinks || []).join(" / ") || "-"}\n` + + `需要确认 ${bundle.acknowledgement_summary?.required_count ?? 0} · 待确认 ${bundle.acknowledgement_summary?.pending_count ?? 0} · 已确认 ${bundle.acknowledgement_summary?.acknowledged_count ?? 0}\n` + + `超时 ${handoffSla.overdue_count ?? 0} · 待处理 ${handoffSla.pending_count ?? 0}\n` + + `任务 ${(bundle.jobs_requiring_handoff || []).map((item) => `${item.job_id}:${item.acknowledgement_status}/${item.handoff_sla_status || "-"}/${item.remote_shipping_status || "-"}`).join(" / ") || "-"}\n` + + `导出 ${handoff.export_path || "-"} · 通知 ${handoff.notification_receipt?.sink_name || "-"}` }) ); - const adapterValidation = appState.opsAsyncJobAdapterValidation || {}; - els.opsAsyncJobAdapterValidation.appendChild( + const adapterValidation = opsState.opsAsyncJobAdapterValidation || {}; + dom.opsAsyncJobAdapterValidation.appendChild( createListCard({ - title: "Async Adapter Config Validation", - score: adapterValidation.valid ? "valid" : "invalid", + title: "异步适配器配置校验", + score: adapterValidation.valid ? "配置正常" : "配置异常", body: - `remote ${adapterValidation.remote_shipping?.valid ? "ok" : "fail"} · default ${(adapterValidation.remote_shipping?.config_source?.resolved_default_adapter || "-")}\n` + - `sinks ${adapterValidation.notification_sinks?.valid ? "ok" : "fail"} · default ${(adapterValidation.notification_sinks?.config_source?.resolved_default_sink || "-")}\n` + - `remote checks ${(adapterValidation.remote_shipping?.checks || []).map((item) => `${item.adapter_name}:${item.valid ? "ok" : (item.issues || []).join("/")}`).join(" / ") || "-"}\n` + - `sink checks ${(adapterValidation.notification_sinks?.checks || []).map((item) => `${item.sink_name}:${item.valid ? "ok" : (item.issues || []).join("/")}`).join(" / ") || "-"}` + `远端 ${adapterValidation.remote_shipping?.valid ? "正常" : "异常"} · 默认 ${(adapterValidation.remote_shipping?.config_source?.resolved_default_adapter || "-")}\n` + + `接收端 ${adapterValidation.notification_sinks?.valid ? "正常" : "异常"} · 默认 ${(adapterValidation.notification_sinks?.config_source?.resolved_default_sink || "-")}\n` + + `远端检查 ${(adapterValidation.remote_shipping?.checks || []).map((item) => `${item.adapter_name}:${item.valid ? "正常" : (item.issues || []).join("/")}`).join(" / ") || "-"}\n` + + `接收端检查 ${(adapterValidation.notification_sinks?.checks || []).map((item) => `${item.sink_name}:${item.valid ? "正常" : (item.issues || []).join("/")}`).join(" / ") || "-"}` }) ); - const adapterProbe = appState.opsAsyncJobAdapterHealthProbe || {}; - els.opsAsyncJobAdapterHealthProbe.appendChild( + const adapterProbe = opsState.opsAsyncJobAdapterHealthProbe || {}; + dom.opsAsyncJobAdapterHealthProbe.appendChild( createListCard({ - title: "Async Adapter Health Probe", + title: "异步适配器健康探测", score: adapterProbe.status || "-", body: - `remote default ${(adapterProbe.remote_shipping?.default_probe?.status || "-")} · ${(adapterProbe.remote_shipping?.default_adapter || "-")}\n` + - `sink default ${(adapterProbe.notification_sinks?.default_probe?.status || "-")} · ${(adapterProbe.notification_sinks?.default_sink || "-")}\n` + - `remote probes ${Object.entries(adapterProbe.remote_shipping?.probes || {}).map(([key, value]) => `${key}=${value.status}`).join(" / ") || "-"}\n` + - `sink probes ${Object.entries(adapterProbe.notification_sinks?.probes || {}).map(([key, value]) => `${key}=${value.status}`).join(" / ") || "-"}` + `远端默认 ${(adapterProbe.remote_shipping?.default_probe?.status || "-")} · ${(adapterProbe.remote_shipping?.default_adapter || "-")}\n` + + `接收端默认 ${(adapterProbe.notification_sinks?.default_probe?.status || "-")} · ${(adapterProbe.notification_sinks?.default_sink || "-")}\n` + + `远端探测 ${Object.entries(adapterProbe.remote_shipping?.probes || {}).map(([key, value]) => `${key}=${value.status}`).join(" / ") || "-"}\n` + + `接收端探测 ${Object.entries(adapterProbe.notification_sinks?.probes || {}).map(([key, value]) => `${key}=${value.status}`).join(" / ") || "-"}` }) ); - const notificationReceipts = appState.opsAsyncJobNotificationReceipts || {}; - els.opsAsyncJobNotificationReceipts.appendChild( + const notificationReceipts = opsState.opsAsyncJobNotificationReceipts || {}; + dom.opsAsyncJobNotificationReceipts.appendChild( createListCard({ - title: "Notification Delivery Receipts", - score: `${notificationReceipts.receipt_count ?? 0} receipts`, + title: "通知送达回执", + score: `${notificationReceipts.receipt_count ?? 0} 条回执`, body: - `sink ${Object.entries(notificationReceipts.by_sink || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `event ${Object.entries(notificationReceipts.by_event_type || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `status ${Object.entries(notificationReceipts.by_status || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `latest ${(notificationReceipts.latest_receipts || []).map((item) => `#${item.event_id || "-"}:${item.sink_name || "-"}:${item.event_type || "-"}:${item.target_exists ? "exists" : "missing"}`).join(" / ") || "-"}` + `接收端 ${Object.entries(notificationReceipts.by_sink || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + + `事件 ${Object.entries(notificationReceipts.by_event_type || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + + `状态 ${Object.entries(notificationReceipts.by_status || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + + `最近回执 ${(notificationReceipts.latest_receipts || []).map((item) => `#${item.event_id || "-"}:${item.sink_name || "-"}:${item.event_type || "-"}:${item.target_exists ? "存在" : "缺失"}`).join(" / ") || "-"}` }) ); - const retryQueue = appState.opsAsyncNotificationRetryQueue || {}; - const retryPolicies = appState.opsAsyncRetryPolicies || {}; - els.opsAsyncNotificationRetryQueue.appendChild( + const retryQueue = opsState.opsAsyncNotificationRetryQueue || {}; + const retryPolicies = opsState.opsAsyncRetryPolicies || {}; + dom.opsAsyncNotificationRetryQueue.appendChild( createListCard({ - title: "Notification Retry Queue", - score: `${retryQueue.retry_count ?? 0} retries`, + title: "通知重试队列", + score: `${retryQueue.retry_count ?? 0} 次重试`, body: - `default policy ${retryPolicies.default_policy_id || "-"}\n` + - `policies ${(retryPolicies.available_policy_ids || []).join(" / ") || "-"}\n` + - `status ${Object.entries(retryQueue.by_status || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `latest ${(retryQueue.retries || []).map((item) => `${item.retry_id || "-"}:${item.status || "-"}:${item.source_event_type || "-"}:${item.process_count || 0}:${item.failure_classification?.failure_class || "-"}/${item.retry_decision || "-"}`).join(" / ") || "-"}` + `默认策略 ${retryPolicies.default_policy_id || "-"}\n` + + `可用策略 ${(retryPolicies.available_policy_ids || []).join(" / ") || "-"}\n` + + `状态 ${Object.entries(retryQueue.by_status || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + + `最近重试 ${(retryQueue.retries || []).map((item) => `${item.retry_id || "-"}:${item.status || "-"}:${item.source_event_type || "-"}:${item.process_count || 0}:${item.failure_classification?.failure_class || "-"}/${item.retry_decision || "-"}`).join(" / ") || "-"}` }) ); - const deadLetters = appState.opsAsyncNotificationDeadLetterQueue || {}; - els.opsAsyncNotificationDeadLetterQueue.appendChild( + const deadLetters = opsState.opsAsyncNotificationDeadLetterQueue || {}; + dom.opsAsyncNotificationDeadLetterQueue.appendChild( createListCard({ - title: "Notification Dead-letter Queue", - score: `${deadLetters.dead_letter_count ?? 0} dead letters`, + title: "通知死信队列", + score: `${deadLetters.dead_letter_count ?? 0} 条死信`, body: `status ${Object.entries(deadLetters.by_status || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + `failure ${Object.entries(deadLetters.by_failure_class || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `latest ${(deadLetters.dead_letters || []).map((item) => `${item.dead_letter_id || "-"}:${item.failure_classification?.failure_class || "-"}`).join(" / ") || "-"}` + `最近死信 ${(deadLetters.dead_letters || []).map((item) => `${item.dead_letter_id || "-"}:${item.failure_classification?.failure_class || "-"}`).join(" / ") || "-"}` }) ); - const retryOutcome = appState.opsAsyncRetryOutcomeDashboard || {}; - els.opsAsyncRetryOutcomeDashboard.appendChild( + const retryOutcome = opsState.opsAsyncRetryOutcomeDashboard || {}; + dom.opsAsyncRetryOutcomeDashboard.appendChild( createListCard({ - title: "Retry Outcome Dashboard", - score: `${retryOutcome.retry_count ?? 0} retries`, + title: "重试结果看板", + score: `${retryOutcome.retry_count ?? 0} 次重试`, body: - `success ${(retryOutcome.successful_retry_count ?? 0)} · planned ${(retryOutcome.planned_retry_count ?? 0)} · terminal ${(retryOutcome.terminal_failure_count ?? 0)} · rate ${retryOutcome.success_rate ?? "-"}\n` + - `status ${Object.entries(retryOutcome.by_status || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `decision ${Object.entries(retryOutcome.by_retry_decision || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `failure ${Object.entries(retryOutcome.by_failure_class || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}` + `成功 ${(retryOutcome.successful_retry_count ?? 0)} · 计划内 ${(retryOutcome.planned_retry_count ?? 0)} · 最终失败 ${(retryOutcome.terminal_failure_count ?? 0)} · 成功率 ${retryOutcome.success_rate ?? "-"}\n` + + `状态 ${Object.entries(retryOutcome.by_status || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + + `决策 ${Object.entries(retryOutcome.by_retry_decision || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + + `失败类型 ${Object.entries(retryOutcome.by_failure_class || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}` }) ); - if (!appState.opsAsyncJobs.length) { - clearNode(els.opsAsyncJobs, "这里会显示 learned training / runtime backup 的异步工作流状态。"); + if (!opsState.opsAsyncJobs.length) { + clearNode(dom.opsAsyncJobs, "这里会显示学习层训练和运行时备份相关的异步工作流状态。"); } else { - appState.opsAsyncJobs.forEach((job) => { + opsState.opsAsyncJobs.forEach((job) => { const card = createListCard({ title: `${job.job_type || "job"} · ${job.job_id || "-"}`, score: job.status || "-", @@ -817,148 +1556,141 @@ function renderOpsJobsSection() { `error ${job.error || "-"}` }); card.addEventListener("click", () => { - if (els.opsAsyncJobId) { - els.opsAsyncJobId.value = job.job_id || ""; + if (dom.opsAsyncJobId) { + dom.opsAsyncJobId.value = job.job_id || ""; } - if (els.opsAsyncJobNote) { - els.opsAsyncJobNote.value = job.acknowledgement_note || ""; + if (dom.opsAsyncJobNote) { + dom.opsAsyncJobNote.value = job.acknowledgement_note || ""; } }); - els.opsAsyncJobs.appendChild(card); + dom.opsAsyncJobs.appendChild(card); }); } } } function renderOpsAccountSection() { - clearNode(els.opsSubscriptionAudit); - clearNode(els.opsAccountWorkspaceSummary); - clearNode(els.opsAccountWorkspaceActions); - clearNode(els.opsAccountWorkspaceTimeline); - if (!appState.opsSubscriptionAudit) { - clearNode(els.opsSubscriptionAudit, "这里会显示当前 account 的 subscription 与 wallets。"); + clearNode(dom.opsSubscriptionAudit); + clearNode(dom.opsAccountWorkspaceSummary); + clearNode(dom.opsAccountWorkspaceActions); + clearNode(dom.opsAccountWorkspaceTimeline); + if (!opsState.opsSubscriptionAudit) { + clearNode(dom.opsSubscriptionAudit, "这里会显示当前账户的订阅与钱包情况。"); } else { - const audit = appState.opsSubscriptionAudit; + const audit = opsState.opsSubscriptionAudit; const card = document.createElement("article"); card.className = "list-card"; - const subscriptions = (audit.subscriptions || []).map((item) => `${item.tier_id} · ${item.status} · ${item.provider}\nperiod ${item.period_end || "-"}\ncancel_at_period_end ${item.cancel_at_period_end ? "yes" : "no"}\nnext ${item.next_action || "-"}\nreason ${item.lifecycle_reason || "-"}`).join("\n\n") || "暂无"; - const entitlements = (audit.entitlements || []).map((item) => `${item.entitlement_id}\n${item.entitlement_type} · ${item.wallet_type || item.tier_id || "-"} · ${item.status}\nbalance ${item.balance ?? "-"} · reason ${item.reason || "-"}`).join("\n\n") || "暂无"; + const subscriptions = (audit.subscriptions || []).map((item) => `${item.tier_id} · ${item.status} · ${item.provider}\n周期结束 ${item.period_end || "-"}\n到期取消 ${item.cancel_at_period_end ? "是" : "否"}\n下一步 ${item.next_action || "-"}\n原因 ${item.lifecycle_reason || "-"}`).join("\n\n") || "暂无"; + const entitlements = (audit.entitlements || []).map((item) => `${item.entitlement_id}\n${item.entitlement_type} · ${item.wallet_type || item.tier_id || "-"} · ${item.status}\n余额 ${item.balance ?? "-"} · 原因 ${item.reason || "-"}`).join("\n\n") || "暂无"; const wallets = Object.entries(audit.wallets || {}) - .map(([walletType, value]) => `${walletType}=${Number(value.balance || 0).toFixed(0)} · ${value.status || "-"}`) + .map(([walletType, value]) => `${walletType}=${Number(value.balance || 0).toFixed(0)} · ${opsStatusLabel(value.status || "-")}`) .join("\n") || "暂无"; const events = (audit.events || []) .map((item) => `${item.event_name} · ${formatTimestamp(item.occurred_at)}\n${Object.entries(item.payload_json || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}`) .join("\n\n") || "暂无"; const matrix = audit.entitlement_matrix || {}; const matrixSummary = [ - `config ${audit.config_version || "-"}`, - `reader continue -> ${(matrix.reader?.continue_story?.required_tier || "-")} / ${(matrix.reader?.continue_story?.wallet_type || "-")}`, - `author brief -> ${(matrix.author?.draft_from_brief?.required_tier || "-")} / ${(matrix.author?.draft_from_brief?.wallet_type || "-")}`, - `author simulate -> ${(matrix.author?.simulate?.required_tier || "-")} / ${(matrix.author?.simulate?.wallet_type || "-")}`, + `配置版本 ${audit.config_version || "-"}`, + `阅读继续 -> ${(matrix.reader?.continue_story?.required_tier || "-")} / ${(matrix.reader?.continue_story?.wallet_type || "-")}`, + `创作起稿 -> ${(matrix.author?.draft_from_brief?.required_tier || "-")} / ${(matrix.author?.draft_from_brief?.wallet_type || "-")}`, + `创作模拟 -> ${(matrix.author?.simulate?.required_tier || "-")} / ${(matrix.author?.simulate?.wallet_type || "-")}`, ].join("\n"); const auditSummary = [ - `count ${audit.audit_summary?.entitlement_count ?? 0}`, - `status ${Object.entries(audit.audit_summary?.status_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}`, - `type ${Object.entries(audit.audit_summary?.entitlement_type_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}`, - `latest ${audit.audit_summary?.latest_event_at || "-"}`, - `lifecycle events ${audit.lifecycle_history_summary?.event_count ?? 0} · retries ${audit.lifecycle_history_summary?.retry_attempt_count ?? 0}`, + `权益数量 ${audit.audit_summary?.entitlement_count ?? 0}`, + `状态 ${Object.entries(audit.audit_summary?.status_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}`, + `类型 ${Object.entries(audit.audit_summary?.entitlement_type_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}`, + `最近时间 ${audit.audit_summary?.latest_event_at || "-"}`, + `生命周期事件 ${audit.lifecycle_history_summary?.event_count ?? 0} · 重试 ${audit.lifecycle_history_summary?.retry_attempt_count ?? 0}`, ].join("\n"); - card.innerHTML = ` -
-

${audit.account_id}

- ${(audit.subscriptions || []).length} subscriptions -
-

subscriptions:\n${subscriptions}\n\nwallets:\n${wallets}\n\nentitlements:\n${entitlements}\n\ncheckout sessions:\n${(audit.recent_checkout_sessions || []).map((item) => `${item.checkout_session_id} · ${item.status} · ${item.tier_id}\nexpires ${item.expires_at || "-"} · subscription ${item.subscription_id || "-"}`).join("\n\n") || "暂无"}\n\naudit summary:\n${auditSummary}\n\nentitlement matrix:\n${matrixSummary}\n\nevents:\n${events}

- `; - els.opsSubscriptionAudit.appendChild(card); + const checkoutSessions = (audit.recent_checkout_sessions || []).map((item) => `${item.checkout_session_id} · ${item.status} · ${item.tier_id}\n过期时间 ${item.expires_at || "-"} · 订阅 ${item.subscription_id || "-"}`).join("\n\n") || "暂无"; + card.innerHTML = renderLocalizedCardHtml( + audit.account_id, + `${(audit.subscriptions || []).length} 条订阅`, + `订阅:\n${subscriptions}\n\n钱包:\n${wallets}\n\n权益:\n${entitlements}\n\n支付会话:\n${checkoutSessions}\n\n审计摘要:\n${auditSummary}\n\n权益矩阵:\n${matrixSummary}\n\n事件:\n${events}` + ); + dom.opsSubscriptionAudit.appendChild(card); } - clearNode(els.opsSubscriptionTimeline); - if (!appState.opsSubscriptionAudit?.audit_timeline?.length) { - clearNode(els.opsSubscriptionTimeline, "这里会显示 entitlement grant / revoke / lifecycle 的审计时间线。"); + clearNode(dom.opsSubscriptionTimeline); + if (!opsState.opsSubscriptionAudit?.audit_timeline?.length) { + clearNode(dom.opsSubscriptionTimeline, "这里会显示权益授予、撤销与生命周期的审计时间线。"); } else { - appState.opsSubscriptionAudit.audit_timeline.forEach((item) => { + opsState.opsSubscriptionAudit.audit_timeline.forEach((item) => { const card = document.createElement("article"); card.className = "list-card"; - card.innerHTML = ` -
-

${item.event_name}

- ${item.status || "-"} -
-

${formatTimestamp(item.occurred_at)}\nentitlement ${item.entitlement_id || "-"}\nsubscription ${item.subscription_id || "-"}\nwallet ${item.wallet_type || "-"} · tier ${item.tier_id || "-"}\nreason ${item.reason || "-"} · balance ${item.balance ?? "-"}

- `; - if (item.entitlement_id && els.opsEntitlementId) { + card.innerHTML = renderLocalizedCardHtml( + item.event_name, + item.status || "-", + `${formatTimestamp(item.occurred_at)}\n权益 ${item.entitlement_id || "-"}\n订阅 ${item.subscription_id || "-"}\n钱包 ${item.wallet_type || "-"} · 档位 ${item.tier_id || "-"}\n原因 ${item.reason || "-"} · 余额 ${item.balance ?? "-"}` + ); + if (item.entitlement_id && dom.opsEntitlementId) { card.addEventListener("click", () => { - els.opsEntitlementId.value = item.entitlement_id; + dom.opsEntitlementId.value = item.entitlement_id; }); } - els.opsSubscriptionTimeline.appendChild(card); + dom.opsSubscriptionTimeline.appendChild(card); }); - (appState.opsSubscriptionAudit?.lifecycle_history_summary?.latest_events || []).forEach((item) => { + (opsState.opsSubscriptionAudit?.lifecycle_history_summary?.latest_events || []).forEach((item) => { const card = document.createElement("article"); card.className = "list-card"; - card.innerHTML = ` -
-

${item.event_type || "-"}

- ${item.status || "-"} -
-

${formatTimestamp(item.occurred_at)}\nprovider ${item.provider || "-"}\nsubscription ${item.subscription_id || "-"}\ncheckout ${item.checkout_session_id || "-"}\nprovider_event ${item.provider_event_id || "-"}\nprocessed ${item.processed_at || "-"}

- `; - if (item.event_id && els.opsBillingEventId) { + card.innerHTML = renderLocalizedCardHtml( + item.event_type || "-", + item.status || "-", + `${formatTimestamp(item.occurred_at)}\n通道 ${item.provider || "-"}\n订阅 ${item.subscription_id || "-"}\n支付会话 ${item.checkout_session_id || "-"}\n通道事件 ${item.provider_event_id || "-"}\n处理时间 ${item.processed_at || "-"}` + ); + if (item.event_id && dom.opsBillingEventId) { card.addEventListener("click", () => { - els.opsBillingEventId.value = item.event_id; + dom.opsBillingEventId.value = item.event_id; }); } - els.opsSubscriptionTimeline.appendChild(card); + dom.opsSubscriptionTimeline.appendChild(card); }); - (appState.opsSubscriptionAudit?.lifecycle_history_summary?.latest_retry_attempts || []).forEach((item) => { + (opsState.opsSubscriptionAudit?.lifecycle_history_summary?.latest_retry_attempts || []).forEach((item) => { const card = document.createElement("article"); card.className = "list-card"; - card.innerHTML = ` -
-

${item.retry_attempt_id}

- ${item.status || "-"} -
-

${formatTimestamp(item.updated_at)}\nsubscription ${item.subscription_id || "-"}\nreason ${item.retry_reason || "-"}\nattempt ${item.attempt_count || 0}\nnext ${item.next_retry_at || "-"}

- `; - els.opsSubscriptionTimeline.appendChild(card); + card.innerHTML = renderLocalizedCardHtml( + item.retry_attempt_id, + item.status || "-", + `${formatTimestamp(item.updated_at)}\n订阅 ${item.subscription_id || "-"}\n原因 ${item.retry_reason || "-"}\n次数 ${item.attempt_count || 0}\n下次时间 ${item.next_retry_at || "-"}` + ); + dom.opsSubscriptionTimeline.appendChild(card); }); } - clearNode(els.opsAccountDetail); - clearNode(els.opsAccountActivity); - clearNode(els.opsSupportSummary); - clearNode(els.opsSupportIssues); - clearNode(els.opsAlertSummary); - clearNode(els.opsAlertFeed); - clearNode(els.opsAlertDetail); - clearNode(els.opsGovernanceSummary); - clearNode(els.opsGovernanceCases); - clearNode(els.opsGovernanceDetail); - clearNode(els.opsGovernanceExport); - clearNode(els.opsAccountAuditSummary); - clearNode(els.opsAccountAuditTrail); - if (!appState.opsAccountDetail) { - clearNode(els.opsAccountWorkspaceSummary, "这里会显示当前 account 的 operator workspace summary。"); - clearNode(els.opsAccountWorkspaceActions, "这里会显示当前 account 的 quick actions 与推荐处置顺序。"); - clearNode(els.opsAccountWorkspaceTimeline, "这里会显示 account 级 operator timeline。"); - clearNode(els.opsAccountDetail, "这里会显示当前 account 的订阅、钱包、gating 与最近 activity。"); - clearNode(els.opsAccountActivity, "这里会显示当前 account 的最近 sessions / drafts / meters。"); - clearNode(els.opsSupportSummary, "这里会显示当前 account 的 support summary 与推荐动作。"); - clearNode(els.opsSupportIssues, "这里会显示当前 account 的 issue lookup 结果。"); - clearNode(els.opsAlertSummary, "这里会显示当前 alert feed 的统计摘要。"); - clearNode(els.opsAlertFeed, "这里会显示主动告警 feed。"); - clearNode(els.opsAlertDetail, "这里会显示选中 alert 的标准处置 bundle、runbook 和 investigation ref。"); - clearNode(els.opsGovernanceSummary, "这里会显示当前 account 的 rights / moderation / abuse case 摘要。"); - clearNode(els.opsGovernanceCases, "这里会显示当前 account 关联的 governance cases。"); - clearNode(els.opsGovernanceDetail, "这里会显示选中 governance case 的 drill-down。"); - clearNode(els.opsGovernanceExport, "这里会显示治理审计导出摘要。"); - clearNode(els.opsAccountAuditSummary, "这里会显示当前 account 的完整审计摘要。"); - clearNode(els.opsAccountAuditTrail, "这里会显示当前 account 的完整 audit trail。"); + clearNode(dom.opsAccountDetail); + clearNode(dom.opsAccountActivity); + clearNode(dom.opsSupportSummary); + clearNode(dom.opsSupportIssues); + clearNode(dom.opsAlertSummary); + clearNode(dom.opsAlertFeed); + clearNode(dom.opsAlertDetail); + clearNode(dom.opsGovernanceSummary); + clearNode(dom.opsGovernanceCases); + clearNode(dom.opsGovernanceDetail); + clearNode(dom.opsGovernanceExport); + clearNode(dom.opsAccountAuditSummary); + clearNode(dom.opsAccountAuditTrail); + if (!opsState.opsAccountDetail) { + clearNode(dom.opsAccountWorkspaceSummary, "这里会显示当前账户的运营摘要。"); + clearNode(dom.opsAccountWorkspaceActions, "这里会显示当前账户的快捷动作与推荐处置顺序。"); + clearNode(dom.opsAccountWorkspaceTimeline, "这里会显示账户级运营时间线。"); + clearNode(dom.opsAccountDetail, "这里会显示当前账户的订阅、钱包、权限与最近活动。"); + clearNode(dom.opsAccountActivity, "这里会显示当前账户最近的会话、草稿和计量记录。"); + clearNode(dom.opsSupportSummary, "这里会显示当前账户的客服摘要与推荐动作。"); + clearNode(dom.opsSupportIssues, "这里会显示当前账户的客服问题定位结果。"); + clearNode(dom.opsAlertSummary, "这里会显示当前告警流的统计摘要。"); + clearNode(dom.opsAlertFeed, "这里会显示主动告警列表。"); + clearNode(dom.opsAlertDetail, "这里会显示选中告警的标准处置包、运行手册和排查引用。"); + clearNode(dom.opsGovernanceSummary, "这里会显示当前账户的权益、治理与滥用个案摘要。"); + clearNode(dom.opsGovernanceCases, "这里会显示当前账户关联的治理个案列表。"); + clearNode(dom.opsGovernanceDetail, "这里会显示选中治理个案的深度详情。"); + clearNode(dom.opsGovernanceExport, "这里会显示治理审计导出摘要。"); + clearNode(dom.opsAccountAuditSummary, "这里会显示当前账户的完整审计摘要。"); + clearNode(dom.opsAccountAuditTrail, "这里会显示当前账户的完整审计轨迹。"); } else { - const detail = appState.opsAccountDetail; - const workspace = appState.opsAccountWorkspace || {}; - const governance = appState.opsGovernanceSnapshot || {}; + const detail = opsState.opsAccountDetail; + const workspace = opsState.opsAccountWorkspace || {}; + const governance = opsState.opsGovernanceSnapshot || {}; const subscription = detail.subscription || {}; const wallets = Object.entries(detail.wallets || {}) .map(([walletType, value]) => `${walletType}=${Number(value.balance || 0).toFixed(0)} · ${value.status || "-"}`) @@ -974,34 +1706,34 @@ function renderOpsAccountSection() { const walletPosture = workspace.wallet_posture || {}; const entitlementPosture = workspace.entitlement_posture || {}; if (!workspace.generated_at) { - clearNode(els.opsAccountWorkspaceSummary, "这里会显示当前 account 的 operator workspace summary。"); - clearNode(els.opsAccountWorkspaceActions, "这里会显示当前 account 的 quick actions 与推荐处置顺序。"); - clearNode(els.opsAccountWorkspaceTimeline, "这里会显示 account 级 operator timeline。"); + clearNode(dom.opsAccountWorkspaceSummary, "这里会显示当前账户的运营摘要。"); + clearNode(dom.opsAccountWorkspaceActions, "这里会显示当前账户的快捷动作与推荐处置顺序。"); + clearNode(dom.opsAccountWorkspaceTimeline, "这里会显示账户级运营时间线。"); } else { - els.opsAccountWorkspaceSummary.appendChild( + dom.opsAccountWorkspaceSummary.appendChild( createListCard({ - title: `Operator Workspace · ${workspace.account_id || detail.account_id}`, - score: workspaceSummary.health_status || "-", + title: `运营摘要 · ${workspace.account_id || detail.account_id}`, + score: opsStatusLabel(workspaceSummary.health_status || "-"), body: - `subscription ${workspaceSummary.subscription_status || "-"} · tier ${workspaceSummary.tier_id || "-"}\n` + - `alerts ${workspaceSummary.actionable_alert_count ?? 0} · support ${workspaceSummary.support_issue_count ?? 0} · governance ${workspaceSummary.open_governance_case_count ?? 0} · restrictions ${workspaceSummary.active_restriction_count ?? 0}\n` + - `recommended path ${workspaceSummary.recommended_path || "-"}\n` + - `reader ${workspaceSummary.surface_statuses?.reader?.status || "-"} · ${workspaceSummary.surface_statuses?.reader?.reason || "-"}\n` + - `author ${workspaceSummary.surface_statuses?.author?.status || "-"} · ${workspaceSummary.surface_statuses?.author?.reason || "-"}\n\n` + - `wallet posture:\n${(walletPosture.wallets || []).map((item) => `${item.wallet_type}=${Number(item.balance || 0).toFixed(0)} · ${item.status || "-"}${item.anomaly ? " · anomaly" : ""}`).join("\n") || "-"}\n\n` + - `entitlements ${entitlementPosture.total_entitlements ?? 0} · revoke candidates ${(entitlementPosture.revoke_candidates || []).length}\n` + - `status ${Object.entries(entitlementPosture.status_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n\n` + - `blockers:\n${(workspace.top_blockers || []).map((item) => `${item.headline} · ${item.severity}\n${item.summary}`).join("\n\n") || "-"}` + `${opsFieldLine("订阅状态", opsStatusLabel(workspaceSummary.subscription_status || "-"))} · ${opsFieldLine("会员方案", workspaceSummary.tier_id || "-")}\n` + + `${opsFieldLine("告警数", workspaceSummary.actionable_alert_count ?? 0)} · ${opsFieldLine("客服问题", workspaceSummary.support_issue_count ?? 0)} · ${opsFieldLine("治理个案", workspaceSummary.open_governance_case_count ?? 0)} · ${opsFieldLine("限制", workspaceSummary.active_restriction_count ?? 0)}\n` + + `${opsFieldLine("推荐路径", workspaceSummary.recommended_path || "-")}\n` + + `${opsFieldLine("阅读面", `${opsStatusLabel(workspaceSummary.surface_statuses?.reader?.status || "-")} · ${workspaceSummary.surface_statuses?.reader?.reason || "-"}`)}\n` + + `${opsFieldLine("创作面", `${opsStatusLabel(workspaceSummary.surface_statuses?.author?.status || "-")} · ${workspaceSummary.surface_statuses?.author?.reason || "-"}`)}\n\n` + + `钱包情况:\n${(walletPosture.wallets || []).map((item) => `${item.wallet_type}=${Number(item.balance || 0).toFixed(0)} · ${opsStatusLabel(item.status || "-")}${item.anomaly ? " · 异常" : ""}`).join("\n") || "-"}\n\n` + + `${opsFieldLine("权益总数", entitlementPosture.total_entitlements ?? 0)} · ${opsFieldLine("可撤销项", (entitlementPosture.revoke_candidates || []).length)}\n` + + `${opsPairsLine("权益状态分布", entitlementPosture.status_counts || {}, opsStatusLabel)}\n\n` + + `阻塞项:\n${(workspace.top_blockers || []).map((item) => `${item.headline} · ${item.severity}\n${item.summary}`).join("\n\n") || "-"}` }) ); if (!(workspace.action_pack || []).length) { - clearNode(els.opsAccountWorkspaceActions, "这里会显示当前 account 的 quick actions 与推荐处置顺序。"); + clearNode(dom.opsAccountWorkspaceActions, "这里会显示当前账户的快捷动作与推荐处置顺序。"); } else { - const actionCard = createListCard({ - title: "Quick Actions", - score: `${(workspace.action_pack || []).length} actions`, - body: (workspace.action_pack || []).map((item) => `${item.label} · ${item.mode}\n${item.reason || "-"}`).join("\n\n"), - }); + const actionCard = createListCard({ + title: "快捷动作", + score: `${(workspace.action_pack || []).length} 个动作`, + body: (workspace.action_pack || []).map((item) => `${item.label} · ${opsActionModeLabel(item.mode)}\n${item.reason || "-"}`).join("\n\n"), + }); const actions = document.createElement("div"); actions.className = "composer-actions"; (workspace.action_pack || []).forEach((item) => { @@ -1010,94 +1742,131 @@ function renderOpsAccountSection() { button.textContent = item.label; button.addEventListener("click", async () => { try { - await runOpsWorkspaceAction(item); + await OpsActionsRuntime.runOpsWorkspaceAction(item); } catch (error) { alert(`执行 workspace action 失败:${error.message}`); } }); actions.appendChild(button); }); - els.opsAccountWorkspaceActions.appendChild(actionCard); - els.opsAccountWorkspaceActions.appendChild(actions); + dom.opsAccountWorkspaceActions.appendChild(actionCard); + dom.opsAccountWorkspaceActions.appendChild(actions); } if (!(workspace.operator_timeline || []).length) { - clearNode(els.opsAccountWorkspaceTimeline, "这里会显示 account 级 operator timeline。"); + clearNode(dom.opsAccountWorkspaceTimeline, "这里会显示账户级运营时间线。"); } else { (workspace.operator_timeline || []).forEach((item) => { const card = document.createElement("article"); card.className = "list-card"; - card.innerHTML = ` -
-

${item.headline || item.entry_id}

- ${item.category || "-"} -
-

${formatTimestamp(item.occurred_at)}\n${item.summary || "-"}\nnext ${(item.next_actions || []).join(" / ") || "-"}

- `; - els.opsAccountWorkspaceTimeline.appendChild(card); + card.innerHTML = renderLocalizedCardHtml( + item.headline || item.entry_id, + item.category || "-", + `${formatTimestamp(item.occurred_at)}\n${item.summary || "-"}\n下一步 ${(item.next_actions || []).join(" / ") || "-"}` + ); + dom.opsAccountWorkspaceTimeline.appendChild(card); }); } } - els.opsAccountDetail.appendChild( + dom.opsAccountDetail.appendChild( createListCard({ - title: `Account Detail · ${detail.account_id}`, - score: subscription.status || "no-subscription", - body: - `subscription ${subscription.tier_id || "-"} · ${subscription.display_name || "-"}\n` + - `provider ${subscription.provider || "-"} · next ${subscription.next_action || "-"} · reason ${subscription.lifecycle_reason || "-"}\n` + - `period ${subscription.period_end || "-"} · renewable ${subscription.renewable ? "yes" : "no"}\n` + - `checkout ${(detail.checkout_session?.checkout_session_id || "-")} · ${(detail.checkout_session?.status || "-")}\n` + - `billing events ${detail.lifecycle_history_summary?.event_count ?? 0} · retries ${detail.lifecycle_history_summary?.retry_attempt_count ?? 0}\n\n` + - `wallets:\n${wallets}\n\n` + - `gating:\n${gatingSummary}\n\n` + - `activity summary:\nmeters ${detail.activity_summary?.recent_meter_count ?? 0} · events ${detail.activity_summary?.recent_event_count ?? 0} · sessions ${detail.activity_summary?.recent_session_count ?? 0} · drafts ${detail.activity_summary?.recent_draft_count ?? 0}` + title: `账户详情 · ${detail.account_id}`, + score: opsStatusLabel(subscription.status || "no-subscription"), + body: opsSections( + opsSection("订阅情况", [ + opsFieldLine("当前方案", `${subscription.tier_id || "-"} · ${subscription.display_name || "-"}`), + opsFieldLine("通道", opsProviderLabel(subscription.provider || "-")), + opsFieldLine("下一步", subscription.next_action || "-"), + opsFieldLine("原因", subscription.lifecycle_reason || "-"), + opsFieldLine("周期结束", subscription.period_end || "-"), + opsFieldLine("是否可续费", opsBooleanLabel(subscription.renewable)), + ]), + opsSection("支付与账单", [ + opsFieldLine("最近支付会话", detail.checkout_session?.checkout_session_id || "-"), + opsFieldLine("支付状态", opsStatusLabel(detail.checkout_session?.status || "-")), + opsFieldLine("账单事件数", detail.lifecycle_history_summary?.event_count ?? 0), + opsFieldLine("重试次数", detail.lifecycle_history_summary?.retry_attempt_count ?? 0), + ]), + opsSection("钱包情况", [wallets]), + opsSection("创作权限", [gatingSummary]), + opsSection("最近活动摘要", [ + opsFieldLine("计量记录", detail.activity_summary?.recent_meter_count ?? 0), + opsFieldLine("事件数", detail.activity_summary?.recent_event_count ?? 0), + opsFieldLine("会话数", detail.activity_summary?.recent_session_count ?? 0), + opsFieldLine("草稿数", detail.activity_summary?.recent_draft_count ?? 0), + ]) + ) }) ); - els.opsAccountActivity.appendChild( + dom.opsAccountActivity.appendChild( createListCard({ - title: "Recent Sessions / Drafts / Meters", + title: "最近会话 / 草稿 / 计量", score: `${(detail.recent_sessions || []).length + (detail.recent_drafts || []).length}`, - body: - `${(detail.recent_sessions || []).length ? `sessions:\n${detail.recent_sessions.map((item) => `${item.session_id} · ${item.world_id}\nturn ${item.current_turn_index} · ${item.last_chapter_title || item.last_event_title || "-"}\naccess ${item.access_tier || "-"} · ${item.reason || "-"}`).join("\n\n")}` : "sessions: -"}\n\n` + - `${(detail.recent_drafts || []).length ? `drafts:\n${detail.recent_drafts.map((item) => `${item.world_version_id} · ${item.status}\n${item.title || item.world_id} · risk ${item.risk_rating || "-"}`).join("\n\n")}` : "drafts: -"}\n\n` + - `${(detail.recent_meters || []).length ? `meters:\n${detail.recent_meters.map((item) => `${item.action_type} · units ${Number(item.usage_units || 0).toFixed(3)} · ${item.wallet_type || "-"}\n${item.world_version_id || "-"} · ${item.session_id || "-"}`).join("\n\n")}` : "meters: -"}` + body: opsSections( + opsSection("最近会话", [ + opsParagraphList(detail.recent_sessions || [], (item) => + `${item.session_id} · ${opsWorldLabel(item.world_id)}\n` + + `${opsFieldLine("当前回合", item.current_turn_index)} · ${opsFieldLine("最近章节", item.last_chapter_title || item.last_event_title || "-")}\n` + + `${opsFieldLine("访问层级", item.access_tier || "-")} · ${opsFieldLine("原因", item.reason || "-")}` + ), + ]), + opsSection("最近草稿", [ + opsParagraphList(detail.recent_drafts || [], (item) => + `${item.world_version_id} · ${opsStatusLabel(item.status)}\n` + + `${item.title || opsWorldLabel(item.world_id)} · ${opsFieldLine("风险等级", item.risk_rating || "-")}` + ), + ]), + opsSection("最近计量", [ + opsParagraphList(detail.recent_meters || [], (item) => + `${item.action_type} · ${opsFieldLine("点数", Number(item.usage_units || 0).toFixed(3))} · ${opsFieldLine("钱包", item.wallet_type || "-")}\n` + + `${opsFieldLine("世界版本", item.world_version_id || "-")} · ${opsFieldLine("会话", item.session_id || "-")}` + ), + ]) + ) }) ); const supportSummary = detail.support_summary || {}; const supportTooling = detail.support_tooling || {}; - els.opsSupportSummary.appendChild( + dom.opsSupportSummary.appendChild( createListCard({ - title: `Support Summary · ${detail.account_id}`, - score: `${supportSummary.open_issue_count ?? 0} issues`, - body: - `primary ${supportSummary.primary_issue_type || "-"}\n` + - `high ${supportSummary.high_priority_issue_count ?? 0} · payment_required ${supportSummary.recent_payment_required_count ?? 0} · checkout ${supportSummary.recent_checkout_started_count ?? 0}\n` + - `types ${Object.entries(supportSummary.issue_type_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `severity ${Object.entries(supportSummary.severity_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `latest ${supportSummary.latest_issue_at || "-"}\n\n` + - `recommended:\n${(supportTooling.recommended_actions || []).map((item) => `${item.label} · ${item.action_type}`).join("\n") || "-"}` + title: `客服摘要 · ${detail.account_id}`, + score: `${supportSummary.open_issue_count ?? 0} 个问题`, + body: opsSections( + opsSection("总体情况", [ + opsFieldLine("主要问题类型", supportSummary.primary_issue_type || "-"), + opsFieldLine("高优先级问题", supportSummary.high_priority_issue_count ?? 0), + opsFieldLine("近期付费受阻", supportSummary.recent_payment_required_count ?? 0), + opsFieldLine("近期发起支付", supportSummary.recent_checkout_started_count ?? 0), + opsFieldLine("最近问题时间", supportSummary.latest_issue_at || "-"), + ]), + opsSection("分布", [ + opsPairsLine("问题类型", supportSummary.issue_type_counts || {}), + opsPairsLine("严重度", supportSummary.severity_counts || {}), + ]), + opsSection("推荐动作", [ + opsList(supportTooling.recommended_actions || [], (item) => `${item.label} · ${localizeDisplayText(item.action_type || "-")}`), + ]) + ) }) ); if (!detail.support_issues?.length) { - clearNode(els.opsSupportIssues, "这里会显示当前 account 的 issue lookup 结果。"); + clearNode(dom.opsSupportIssues, "这里会显示当前账户的客服问题定位结果。"); } else { detail.support_issues.forEach((issue) => { const card = document.createElement("article"); card.className = "list-card"; const actionsMarkup = (issue.suggested_operator_actions || []) .map((action, index) => ``) - .join("") + ``; + .join("") + ``; const linkedCases = (governance.support_issue_refs || []).find((item) => item.issue_id === issue.issue_id)?.linked_cases || []; - card.innerHTML = ` -
-

${issue.title}

- ${issue.severity || "-"} -
-

${issue.summary || "-"}\nreason ${issue.reason || "-"} · detected ${issue.detected_at || "-"}\nsurfaces ${(issue.surfaces || []).join(" / ") || "-"}\nlinked cases ${linkedCases.map((item) => `${item.case_id}:${item.status}`).join(" / ") || "-"}\nobjects ${Object.entries(issue.related_objects || {}).map(([key, value]) => `${key}=${Array.isArray(value) ? value.join(",") : value}`).join(" / ") || "-"}\nevidence ${Object.entries(issue.evidence || {}).map(([key, value]) => `${key}=${typeof value === "object" ? JSON.stringify(value) : value}`).join(" / ") || "-"}

-
${actionsMarkup}
- `; + card.innerHTML = renderLocalizedCardHtml( + issue.title, + issue.severity || "-", + `${issue.summary || "-"}\n原因 ${issue.reason || "-"} · 发现时间 ${issue.detected_at || "-"}\n触达面 ${(issue.surfaces || []).join(" / ") || "-"}\n关联个案 ${linkedCases.map((item) => `${item.case_id}:${item.status}`).join(" / ") || "-"}\n关联对象 ${Object.entries(issue.related_objects || {}).map(([key, value]) => `${key}=${Array.isArray(value) ? value.join(",") : value}`).join(" / ") || "-"}\n证据 ${Object.entries(issue.evidence || {}).map(([key, value]) => `${key}=${typeof value === "object" ? JSON.stringify(value) : value}`).join(" / ") || "-"}`, + `
${actionsMarkup}
` + ); card.querySelectorAll(".support-prefill").forEach((button) => { button.addEventListener("click", () => { const actionIndex = Number(button.getAttribute("data-action-index") || 0); @@ -1106,58 +1875,56 @@ function renderOpsAccountSection() { }); }); card.querySelector(".support-escalate")?.addEventListener("click", () => escalateSupportIssue(issue)); - els.opsSupportIssues.appendChild(card); + dom.opsSupportIssues.appendChild(card); }); } - const alertFeed = appState.opsAlertsFeed || {}; + const alertFeed = opsState.opsAlertsFeed || {}; if (!alertFeed.alerts?.length) { - clearNode(els.opsAlertSummary, "这里会显示当前 alert feed 的统计摘要。"); - clearNode(els.opsAlertFeed, "这里会显示主动告警 feed。"); - clearNode(els.opsAlertDetail, "这里会显示选中 alert 的标准处置 bundle、runbook 和 investigation ref。"); + clearNode(dom.opsAlertSummary, "这里会显示当前告警流的统计摘要。"); + clearNode(dom.opsAlertFeed, "这里会显示主动告警列表。"); + clearNode(dom.opsAlertDetail, "这里会显示选中告警的标准处置包、运行手册和排查引用。"); } else { const alertSummary = alertFeed.summary || {}; - els.opsAlertSummary.appendChild( + dom.opsAlertSummary.appendChild( createListCard({ - title: "Alert Feed Summary", - score: `${alertSummary.actionable_alert_count ?? 0} actionable`, + title: "告警摘要", + score: `${alertSummary.actionable_alert_count ?? 0} 条待处理`, body: - `latest ${alertSummary.latest_detected_at ? formatTimestamp(alertSummary.latest_detected_at) : "-"}\n` + - `category ${Object.entries(alertSummary.by_category || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `severity ${Object.entries(alertSummary.by_severity || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `status ${Object.entries(alertSummary.by_status || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}` + `${opsFieldLine("最新告警时间", alertSummary.latest_detected_at ? formatTimestamp(alertSummary.latest_detected_at) : "-")}\n` + + `${opsPairsLine("分类分布", alertSummary.by_category || {})}\n` + + `${opsPairsLine("严重度分布", alertSummary.by_severity || {})}\n` + + `${opsPairsLine("状态分布", alertSummary.by_status || {}, opsStatusLabel)}` }) ); alertFeed.alerts.forEach((item) => { const card = document.createElement("article"); card.className = "list-card"; - if (item.alert_id === appState.selectedOpsAlertId) { + if (item.alert_id === opsState.selectedOpsAlertId) { card.classList.add("is-active"); } - card.innerHTML = ` -
-

${item.title || item.alert_id}

- ${item.severity || "-"} · ${item.status || "-"} -
-

${formatTimestamp(item.detected_at)}\n${item.category || "-"} · ${item.source_type || "-"}\naccount ${item.account_id || "global"}\n${item.summary || "-"}\nnext ${(item.recommended_actions || []).join(" / ") || "-"}

- `; + card.innerHTML = renderLocalizedCardHtml( + item.title || item.alert_id, + `${item.severity || "-"} · ${opsStatusLabel(item.status || "-")}`, + `${formatTimestamp(item.detected_at)}\n${item.category || "-"} · ${item.source_type || "-"}\n账户 ${item.account_id || "全局"}\n${item.summary || "-"}\n下一步 ${(item.recommended_actions || []).join(" / ") || "-"}` + ); card.addEventListener("click", async () => { - appState.selectedOpsAlertId = item.alert_id; + opsState.selectedOpsAlertId = item.alert_id; const accountId = currentOpsAlertFilters().accountId || item.account_id || ""; syncOpsNavigationContext({ account_id: accountId, alert_id: item.alert_id }, { preserveExisting: true }); - appState.opsAlertDetail = await api( + opsState.opsAlertDetail = await api( `/v1/ops/alerts/${encodeURIComponent(item.alert_id)}${ accountId ? `?account_id=${encodeURIComponent(accountId)}` : "" }` ); renderOpsSurface(); }); - els.opsAlertFeed.appendChild(card); + dom.opsAlertFeed.appendChild(card); }); - if (!appState.opsAlertDetail) { - clearNode(els.opsAlertDetail, "这里会显示选中 alert 的标准处置 bundle、runbook 和 investigation ref。"); + if (!opsState.opsAlertDetail) { + clearNode(dom.opsAlertDetail, "这里会显示选中告警的标准处置包、运行手册和排查引用。"); } else { - const detailPayload = appState.opsAlertDetail; + const detailPayload = opsState.opsAlertDetail; const alert = detailPayload.alert || {}; const runbook = detailPayload.runbook || {}; const responseBundle = detailPayload.standard_response_bundle || {}; @@ -1165,21 +1932,21 @@ function renderOpsAccountSection() { const supportActions = (responseBundle.recommended_actions || []) .concat((responseBundle.support_issue?.suggested_operator_actions || []).map((item) => item.action_type)) .filter(Boolean); - els.opsAlertDetail.appendChild( + dom.opsAlertDetail.appendChild( createListCard({ - title: `Alert Detail · ${alert.alert_id || "-"}`, - score: `${alert.status || "-"} · ${alert.severity || "-"}`, + title: `告警详情 · ${alert.alert_id || "-"}`, + score: `${opsStatusLabel(alert.status || "-")} · ${alert.severity || "-"}`, body: - `account ${alert.account_id || "global"} · category ${alert.category || "-"} · source ${alert.source_type || "-"}\n` + - `detected ${alert.detected_at ? formatTimestamp(alert.detected_at) : "-"}\n` + - `summary ${alert.summary || "-"}\n` + - `refs ${(alert.source_refs || []).map((item) => `${item.label || item.kind}:${item.ref_id || "-"}`).join(" / ") || "-"}\n` + - `recommended ${(alert.recommended_actions || []).join(" / ") || "-"}\n` + - `SOP ${(alert.standard_operating_path || []).join(" -> ") || "-"}\n` + - `owner ${alert.state?.reviewer_id || "-"} · note ${alert.state?.note || "-"}\n\n` + - `investigation ref:\naccount ${investigation.account_id || "-"} · world ${investigation.world_version_id || "-"} · case ${investigation.case_id || "-"}\n\n` + - `runbook:\ntriage ${(runbook.triage_steps || []).join(" / ") || "-"}\nrecovery ${(runbook.recovery_steps || []).join(" / ") || "-"}\n\n` + - `standard response:\n${supportActions.join(" / ") || (responseBundle.recommended_next_actions || []).join(" / ") || (runbook.standard_actions || []).join(" / ") || "-"}` + `${opsFieldLine("账户", alert.account_id || "全局")} · ${opsFieldLine("分类", alert.category || "-")} · ${opsFieldLine("来源", alert.source_type || "-")}\n` + + `${opsFieldLine("发现时间", alert.detected_at ? formatTimestamp(alert.detected_at) : "-")}\n` + + `${opsFieldLine("摘要", alert.summary || "-")}\n` + + `${opsFieldLine("关联引用", (alert.source_refs || []).map((item) => `${item.label || item.kind}:${item.ref_id || "-"}`).join(" / ") || "-")}\n` + + `${opsFieldLine("推荐动作", (alert.recommended_actions || []).join(" / ") || "-")}\n` + + `${opsFieldLine("标准路径", (alert.standard_operating_path || []).join(" → ") || "-")}\n` + + `${opsFieldLine("当前处理人", alert.state?.reviewer_id || "-")} · ${opsFieldLine("备注", alert.state?.note || "-")}\n\n` + + `排查引用:\n${opsFieldLine("账户", investigation.account_id || "-")} · ${opsFieldLine("世界版本", investigation.world_version_id || "-")} · ${opsFieldLine("治理个案", investigation.case_id || "-")}\n\n` + + `运行手册:\n${opsFieldLine("分诊步骤", (runbook.triage_steps || []).join(" / ") || "-")}\n${opsFieldLine("恢复步骤", (runbook.recovery_steps || []).join(" / ") || "-")}\n\n` + + `标准响应:\n${supportActions.join(" / ") || (responseBundle.recommended_next_actions || []).join(" / ") || (runbook.standard_actions || []).join(" / ") || "-"}` }) ); } @@ -1187,21 +1954,21 @@ function renderOpsAccountSection() { const governanceSummary = governance.governance_summary || {}; const restrictionSummary = governance.restriction_summary || {}; - els.opsGovernanceSummary.appendChild( + dom.opsGovernanceSummary.appendChild( createListCard({ - title: `Governance Summary · ${detail.account_id}`, - score: `${governanceSummary.open_case_count ?? 0} open`, + title: `治理摘要 · ${detail.account_id}`, + score: `${governanceSummary.open_case_count ?? 0} 个待处理`, body: - `total ${governanceSummary.total_cases ?? 0} · escalated ${governanceSummary.escalated_case_count ?? 0}\n` + - `active restrictions ${restrictionSummary.active_restriction_count ?? 0}\n` + - `overdue ${governanceSummary.overdue_case_count ?? 0}\n` + - `status ${Object.entries(governanceSummary.status_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `type ${Object.entries(governanceSummary.case_type_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `severity ${Object.entries(governanceSummary.severity_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `queue ${Object.entries(governanceSummary.queue_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `owners ${Object.entries(governanceSummary.owner_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `latest ${governanceSummary.latest_case_id || "-"} · ${governanceSummary.latest_case_at || "-"}\n\n` + - `recommended:\n${(governance.recommended_case_prefills || []).map((item) => item.label).join("\n") || "-"}` + `${opsFieldLine("个案总数", governanceSummary.total_cases ?? 0)} · ${opsFieldLine("已升级", governanceSummary.escalated_case_count ?? 0)}\n` + + `${opsFieldLine("生效中的限制", restrictionSummary.active_restriction_count ?? 0)}\n` + + `${opsFieldLine("逾期个案", governanceSummary.overdue_case_count ?? 0)}\n` + + `${opsPairsLine("状态分布", governanceSummary.status_counts || {}, opsStatusLabel)}\n` + + `个案类型 ${Object.entries(governanceSummary.case_type_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + + `严重度 ${Object.entries(governanceSummary.severity_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + + `队列 ${Object.entries(governanceSummary.queue_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + + `负责人 ${Object.entries(governanceSummary.owner_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + + `最近个案 ${governanceSummary.latest_case_id || "-"} · ${governanceSummary.latest_case_at || "-"}\n\n` + + `推荐入口:\n${(governance.recommended_case_prefills || []).map((item) => item.label).join("\n") || "-"}` }) ); if (governance.recommended_case_prefills?.length) { @@ -1214,24 +1981,22 @@ function renderOpsAccountSection() { button.addEventListener("click", () => applyGovernanceCasePrefill(item.prefill || {})); actions.appendChild(button); }); - els.opsGovernanceSummary.appendChild(actions); + dom.opsGovernanceSummary.appendChild(actions); } if (!governance.governance_cases?.length) { - clearNode(els.opsGovernanceCases, "这里会显示当前 account 关联的 governance cases。"); + clearNode(dom.opsGovernanceCases, "这里会显示当前账户关联的治理个案列表。"); } else { governance.governance_cases.forEach((item) => { const card = document.createElement("article"); card.className = "list-card"; const restriction = item.restriction || {}; const workflow = item.workflow_summary || {}; - card.innerHTML = ` -
-

${item.summary || item.case_id}

- ${item.status || "-"} -
-

${item.case_id}\n${item.case_type || "-"} · ${item.queue || "-"} · ${item.severity || "-"}\ntarget ${item.target_type || "-"}:${item.target_id || "-"}\nowner ${workflow.owner_id || item.owner_id || "-"} · due ${workflow.due_at || item.due_at || "-"} · overdue ${workflow.is_overdue ? "yes" : "no"}\nreviewer ${item.reviewer_id || "-"} · updated ${item.updated_at || "-"}\npolicy ${(workflow.policy_labels || item.policy_labels || []).join(" / ") || "-"} · disposition ${workflow.disposition || item.disposition || "-"}\nevidence ${workflow.evidence_count ?? (item.evidence_refs || []).length ?? 0} · support ${(item.support_issue_ids || []).join(" / ") || "-"}\nrestriction ${restriction.restriction_id || "-"} · ${restriction.restriction_type || "-"} · ${restriction.status || "-"}\nresolution ${item.resolution_notes || "-"}\ntransitions ${(item.status_transitions || []).map((entry) => `${entry.status}@${entry.changed_at}`).join(" / ") || "-"}

- `; + card.innerHTML = renderLocalizedCardHtml( + item.summary || item.case_id, + opsStatusLabel(item.status || "-"), + `${item.case_id}\n${item.case_type || "-"} · ${item.queue || "-"} · ${item.severity || "-"}\n目标 ${item.target_type || "-"}:${item.target_id || "-"}\n负责人 ${workflow.owner_id || item.owner_id || "-"} · 到期 ${item.due_at || workflow.due_at || "-"} · 是否逾期 ${workflow.is_overdue ? "是" : "否"}\n审阅人 ${item.reviewer_id || "-"} · 更新时间 ${item.updated_at || "-"}\n策略 ${(workflow.policy_labels || item.policy_labels || []).join(" / ") || "-"} · 处置结论 ${workflow.disposition || item.disposition || "-"}\n证据数 ${workflow.evidence_count ?? (item.evidence_refs || []).length ?? 0} · 客服问题 ${(item.support_issue_ids || []).join(" / ") || "-"}\n限制 ${restriction.restriction_id || "-"} · ${restriction.restriction_type || "-"} · ${opsStatusLabel(restriction.status || "-")}\n处理说明 ${item.resolution_notes || "-"}\n状态流转 ${(item.status_transitions || []).map((entry) => `${opsStatusLabel(entry.status || "-")}@${entry.changed_at}`).join(" / ") || "-"}` + ); card.addEventListener("click", () => { applyGovernanceCasePrefill({ case_id: item.case_id, @@ -1260,170 +2025,221 @@ function renderOpsAccountSection() { ); openGovernanceCaseDetail(item.case_id); }); - els.opsGovernanceCases.appendChild(card); + dom.opsGovernanceCases.appendChild(card); }); } - if (!appState.opsGovernanceDetail) { - clearNode(els.opsGovernanceDetail, "这里会显示选中 governance case 的 drill-down。"); + if (!opsState.opsGovernanceDetail) { + clearNode(dom.opsGovernanceDetail, "这里会显示选中治理个案的深度详情。"); } else { - const item = appState.opsGovernanceDetail; + const item = opsState.opsGovernanceDetail; const restriction = item.restriction || {}; const workflow = item.workflow_summary || {}; const permissions = item.permission_summary || {}; - els.opsGovernanceDetail.appendChild( + dom.opsGovernanceDetail.appendChild( createListCard({ - title: `Governance Detail · ${item.case_id}`, - score: item.status || "-", + title: `治理详情 · ${item.case_id}`, + score: opsStatusLabel(item.status || "-"), body: - `case ${item.case_type || "-"} · ${item.queue || "-"} · ${item.severity || "-"}\n` + - `target ${item.target_type || "-"}:${item.target_id || "-"} · reviewer ${item.reviewer_id || "-"}\n` + - `owner ${workflow.owner_id || item.owner_id || "-"} · due ${workflow.due_at || item.due_at || "-"} · overdue ${workflow.is_overdue ? "yes" : "no"}\n` + - `support ${(item.support_issue_ids || []).join(" / ") || "-"}\n` + - `restriction ${restriction.restriction_id || "-"} · ${restriction.restriction_type || "-"} · ${restriction.status || "-"}\n` + - `policy ${(workflow.policy_labels || item.policy_labels || []).join(" / ") || "-"} · disposition ${workflow.disposition || item.disposition || "-"}\n` + - `transition options ${(workflow.transition_options || []).join(" / ") || "-"}\n` + - `permissions claim=${permissions.can_claim ? "yes" : "no"} assign=${permissions.can_assign ? "yes" : "no"} evidence=${permissions.can_add_evidence ? "yes" : "no"} transition=${permissions.can_transition ? "yes" : "no"} release=${permissions.can_release_restriction ? "yes" : "no"}\n` + - `summary ${item.summary || "-"}\nresolution ${item.resolution_notes || "-"}\n\n` + - `workflow checklist:\n${(item.workflow_checklist || []).map((entry) => `${entry.key} · ${entry.status}\n${entry.label}${entry.note ? ` · ${entry.note}` : ""}`).join("\n\n") || "-"}\n\n` + - `evidence:\n${(item.evidence_refs || []).map((entry) => `${entry.title || entry.kind} · ${entry.kind}\n${entry.ref_id || "-"} · ${entry.preview || "-"}`).join("\n\n") || "-"}\n\n` + - `next actions:\n${(item.recommended_next_actions || []).join("\n") || "-"}\n\n` + - `transitions:\n${(item.status_transitions || []).map((entry) => `${entry.status} · ${entry.reviewer_id || "-"} · ${entry.changed_at}\n${entry.notes || "-"}`).join("\n\n") || "-"}\n\n` + - `linked support:\n${(item.linked_support_issues || []).map((issue) => `${issue.issue_id} · ${issue.issue_type} · ${issue.severity}\n${issue.title || "-"}\n${issue.summary || "-"}`).join("\n\n") || "-"}\n\n` + - `audit events:\n${(item.audit_events || []).map((event) => `${event.action} · ${event.status || "-"} · ${formatTimestamp(event.occurred_at)}\n${event.reason || "-"} · ${event.object_type || "-"}:${event.object_id || "-"}`).join("\n\n") || "-"}` + `${opsFieldLine("个案类型", item.case_type || "-")} · ${opsFieldLine("队列", item.queue || "-")} · ${opsFieldLine("严重度", item.severity || "-")}\n` + + `${opsFieldLine("目标", `${item.target_type || "-"}:${item.target_id || "-"}`)} · ${opsFieldLine("审阅人", item.reviewer_id || "-")}\n` + + `${opsFieldLine("负责人", workflow.owner_id || item.owner_id || "-")} · ${opsFieldLine("到期时间", workflow.due_at || item.due_at || "-")} · ${opsFieldLine("是否逾期", opsBooleanLabel(workflow.is_overdue))}\n` + + `${opsFieldLine("关联客服问题", (item.support_issue_ids || []).join(" / ") || "-")}\n` + + `${opsFieldLine("限制", `${restriction.restriction_id || "-"} · ${restriction.restriction_type || "-"} · ${opsStatusLabel(restriction.status || "-")}`)}\n` + + `${opsFieldLine("策略标签", (workflow.policy_labels || item.policy_labels || []).join(" / ") || "-")} · ${opsFieldLine("处置结论", workflow.disposition || item.disposition || "-")}\n` + + `${opsFieldLine("可用流转", (workflow.transition_options || []).join(" / ") || "-")}\n` + + `${opsFieldLine("权限", `认领 ${opsBooleanLabel(permissions.can_claim)} / 分派 ${opsBooleanLabel(permissions.can_assign)} / 证据 ${opsBooleanLabel(permissions.can_add_evidence)} / 流转 ${opsBooleanLabel(permissions.can_transition)} / 释放限制 ${opsBooleanLabel(permissions.can_release_restriction)}`)}\n` + + `${opsFieldLine("摘要", item.summary || "-")}\n${opsFieldLine("处理说明", item.resolution_notes || "-")}\n\n` + + `流程清单:\n${(item.workflow_checklist || []).map((entry) => `${entry.key} · ${opsStatusLabel(entry.status)}\n${entry.label}${entry.note ? ` · ${entry.note}` : ""}`).join("\n\n") || "-"}\n\n` + + `证据:\n${(item.evidence_refs || []).map((entry) => `${entry.title || entry.kind} · ${entry.kind}\n${entry.ref_id || "-"} · ${entry.preview || "-"}`).join("\n\n") || "-"}\n\n` + + `下一步动作:\n${(item.recommended_next_actions || []).join("\n") || "-"}\n\n` + + `状态流转:\n${(item.status_transitions || []).map((entry) => `${opsStatusLabel(entry.status)} · ${entry.reviewer_id || "-"} · ${entry.changed_at}\n${entry.notes || "-"}`).join("\n\n") || "-"}\n\n` + + `关联客服问题:\n${(item.linked_support_issues || []).map((issue) => `${issue.issue_id} · ${issue.issue_type} · ${issue.severity}\n${issue.title || "-"}\n${issue.summary || "-"}`).join("\n\n") || "-"}\n\n` + + `审计事件:\n${(item.audit_events || []).map((event) => `${event.action} · ${opsStatusLabel(event.status || "-")} · ${formatTimestamp(event.occurred_at)}\n${event.reason || "-"} · ${event.object_type || "-"}:${event.object_id || "-"}`).join("\n\n") || "-"}` }) ); } - const governanceExport = appState.opsGovernanceExport || {}; + const governanceExport = opsState.opsGovernanceExport || {}; if (!governanceExport.cases?.length && !governanceExport.restrictions?.length) { - clearNode(els.opsGovernanceExport, "这里会显示治理审计导出摘要。"); + clearNode(dom.opsGovernanceExport, "这里会显示治理审计导出摘要。"); } else { - els.opsGovernanceExport.appendChild( + dom.opsGovernanceExport.appendChild( createListCard({ - title: `Governance Audit Export · ${detail.account_id}`, - score: `${governanceExport.governance_summary?.total_cases ?? 0} cases`, - body: - `generated ${governanceExport.export_generated_at || "-"}\n` + - `case status ${Object.entries(governanceExport.governance_summary?.status_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `restriction status ${Object.entries(governanceExport.restriction_summary?.status_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `restriction type ${Object.entries(governanceExport.restriction_summary?.type_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n\n` + - `restrictions:\n${(governanceExport.restrictions || []).map((item) => `${item.restriction_id} · ${item.restriction_type} · ${item.status}\ncase ${item.case_id} · target ${item.target_type}:${item.target_id}`).join("\n\n") || "-"}` + title: `治理审计导出 · ${detail.account_id}`, + score: `${governanceExport.governance_summary?.total_cases ?? 0} 个个案`, + body: opsSections( + opsSection("生成信息", [ + opsFieldLine("导出时间", governanceExport.export_generated_at || "-"), + opsFieldLine("个案总数", governanceExport.governance_summary?.total_cases ?? 0), + opsFieldLine("限制总数", governanceExport.restriction_summary?.total_restrictions ?? (governanceExport.restrictions || []).length), + ]), + opsSection("个案状态", [ + opsPairsLine("状态分布", governanceExport.governance_summary?.status_counts || {}, opsStatusLabel), + ]), + opsSection("限制分布", [ + opsPairsLine("限制状态", governanceExport.restriction_summary?.status_counts || {}, opsStatusLabel), + opsPairsLine("限制类型", governanceExport.restriction_summary?.type_counts || {}), + ]), + opsSection("个案明细", [ + opsParagraphList(governanceExport.cases || [], (item) => opsSections( + opsSection(`${item.case_id || "-"} · ${opsStatusLabel(item.status || "-")}`, [ + opsFieldLine("个案类型", item.case_type || "-"), + opsFieldLine("队列", item.queue || "-"), + opsFieldLine("严重度", item.severity || "-"), + opsFieldLine("目标", opsTargetLabel(item.target_type, item.target_id)), + opsFieldLine("审阅人", item.reviewer_id || "-"), + ]) + )), + ]), + opsSection("限制明细", [ + opsParagraphList(governanceExport.restrictions || [], (item) => opsSections( + opsSection(`${item.restriction_id || "-"} · ${opsStatusLabel(item.status || "-")}`, [ + opsFieldLine("限制类型", item.restriction_type || "-"), + opsFieldLine("关联个案", item.case_id || "-"), + opsFieldLine("目标", opsTargetLabel(item.target_type, item.target_id)), + ]) + )), + ]) + ) }) ); } const auditBreakdown = detail.audit_breakdown || {}; const auditSummary = [ - `total ${auditBreakdown.total_entries ?? 0}`, - `latest ${auditBreakdown.latest_at || "-"}`, - `categories ${Object.entries(auditBreakdown.by_category || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}`, - `surfaces ${Object.entries(auditBreakdown.by_surface || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}`, - `sources ${Object.entries(auditBreakdown.sources || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}`, - `top actions ${((auditBreakdown.top_actions || []).map((item) => `${item.action}=${item.count}`).join(" / ")) || "-"}`, - `cursor ${detail.timeline_cursor?.returned ?? 0}/${detail.timeline_cursor?.limit ?? 0} · more ${detail.timeline_cursor?.has_more ? "yes" : "no"}`, + `总数 ${auditBreakdown.total_entries ?? 0}`, + `最近时间 ${auditBreakdown.latest_at || "-"}`, + `分类 ${Object.entries(auditBreakdown.by_category || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}`, + `触达面 ${Object.entries(auditBreakdown.by_surface || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}`, + `来源 ${Object.entries(auditBreakdown.sources || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}`, + `高频动作 ${((auditBreakdown.top_actions || []).map((item) => `${item.action}=${item.count}`).join(" / ")) || "-"}`, + `游标 ${detail.timeline_cursor?.returned ?? 0}/${detail.timeline_cursor?.limit ?? 0} · 还有更多 ${detail.timeline_cursor?.has_more ? "是" : "否"}`, ].join("\n"); - els.opsAccountAuditSummary.appendChild( + dom.opsAccountAuditSummary.appendChild( createListCard({ - title: "Audit Breakdown", - score: `${auditBreakdown.total_entries ?? 0} entries`, + title: "审计拆解", + score: `${auditBreakdown.total_entries ?? 0} 条记录`, body: auditSummary, }) ); if (!detail.audit_trail?.length) { - clearNode(els.opsAccountAuditTrail, "这里会显示当前 account 的完整 audit trail。"); + clearNode(dom.opsAccountAuditTrail, "这里会显示当前账户的完整审计轨迹。"); } else { detail.audit_trail.forEach((item) => { const card = document.createElement("article"); card.className = "list-card"; - card.innerHTML = ` -
-

${item.action || "-"}

- ${item.category || "-"} -
-

${formatTimestamp(item.occurred_at)}\n${item.surface || "-"} · ${item.source_type || "-"}\nactor ${item.actor_id || "-"} → ${item.object_type || "-"} ${item.object_id || "-"}\nstatus ${item.status || "-"} · reason ${item.reason || "-"}\nwallet ${item.wallet_type || "-"} · tier ${item.tier_id || "-"} · units ${item.usage_units ?? "-"}\nworld ${item.world_version_id || item.world_id || "-"} · session ${item.session_id || "-"}

- `; - if (item.object_type === "entitlement" && item.object_id && els.opsEntitlementId) { + card.innerHTML = renderLocalizedCardHtml( + item.action || "-", + item.category || "-", + `${formatTimestamp(item.occurred_at)}\n${item.surface || "-"} · ${item.source_type || "-"}\n操作人 ${item.actor_id || "-"} → ${item.object_type || "-"} ${item.object_id || "-"}\n状态 ${opsStatusLabel(item.status || "-")} · 原因 ${item.reason || "-"}\n钱包 ${item.wallet_type || "-"} · 档位 ${item.tier_id || "-"} · 点数 ${item.usage_units ?? "-"}\n世界 ${item.world_version_id || item.world_id || "-"} · 会话 ${item.session_id || "-"}` + ); + if (item.object_type === "entitlement" && item.object_id && dom.opsEntitlementId) { card.addEventListener("click", () => { - els.opsEntitlementId.value = item.object_id; + dom.opsEntitlementId.value = item.object_id; }); } - els.opsAccountAuditTrail.appendChild(card); + dom.opsAccountAuditTrail.appendChild(card); }); } } } function renderOpsInvestigationSection() { - clearNode(els.opsInvestigationSummary); - clearNode(els.opsInvestigationTimeline); - clearNode(els.opsInvestigationEvidence); - if (!appState.opsInvestigationBundle) { - clearNode(els.opsInvestigationSummary, "这里会显示 investigation summary 与推荐排查路径。"); - clearNode(els.opsInvestigationTimeline, "这里会显示统一 trace timeline。"); - clearNode(els.opsInvestigationEvidence, "这里会显示 evidence index。"); + clearNode(dom.opsInvestigationSummary); + clearNode(dom.opsInvestigationTimeline); + clearNode(dom.opsInvestigationEvidence); + if (!opsState.opsInvestigationBundle) { + clearNode(dom.opsInvestigationSummary, "这里会显示排查摘要与推荐排查路径。"); + clearNode(dom.opsInvestigationTimeline, "这里会显示统一排查时间线。"); + clearNode(dom.opsInvestigationEvidence, "这里会显示证据索引。"); } else { - const bundle = appState.opsInvestigationBundle; + const bundle = opsState.opsInvestigationBundle; const summary = bundle.investigation_summary || {}; const filters = bundle.filters || {}; const linked = bundle.linked_entities || {}; + const authorCapability = bundle.author_longform_capability || {}; + const authorAlignment = bundle.author_claim_alignment || {}; const recommended = (bundle.recommended_paths || []) .map((item, index) => `${index + 1}. ${item.path_id} · score ${item.score ?? 0}\n${item.reason || "-"}`) .join("\n\n") || "-"; - els.opsInvestigationSummary.appendChild( + dom.opsInvestigationSummary.appendChild( createListCard({ - title: `Investigation Summary · ${filters.account_id || linked.account_id || "-"}`, - score: `${summary.trace_count ?? 0} traces`, + title: `排查摘要 · ${filters.account_id || linked.account_id || "-"}`, + score: `${summary.trace_count ?? 0} 条轨迹`, body: - `generated ${formatTimestamp(bundle.generated_at)}\n` + - `filters account ${filters.account_id || "-"} · world ${filters.world_version_id || "-"} · case ${filters.case_id || "-"} · limit ${filters.limit || "-"}\n` + - `linked subscription ${linked.subscription_id || "-"} · checkout ${linked.checkout_session_id || "-"}\n` + - `governance ${(linked.governance_case_ids || []).join(" / ") || "-"}\n` + - `world versions ${(linked.world_version_ids || []).join(" / ") || "-"}\n` + - `support issues ${(linked.support_issue_ids || []).join(" / ") || "-"}\n\n` + - `summary:\n` + - `latest ${summary.latest_at ? formatTimestamp(summary.latest_at) : "-"}\n` + - `categories ${Object.entries(summary.category_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `severity ${Object.entries(summary.severity_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + - `restrictions ${summary.active_restriction_count ?? 0} · support ${summary.open_support_issue_count ?? 0} · billing events ${summary.billing_event_count ?? 0} · retries ${summary.billing_retry_attempt_count ?? 0}\n\n` + - `recommended paths:\n${recommended}` + `生成时间 ${formatTimestamp(bundle.generated_at)}\n` + + `筛选条件 账户 ${filters.account_id || "-"} · 世界 ${filters.world_version_id || "-"} · 个案 ${filters.case_id || "-"} · 数量上限 ${filters.limit || "-"}\n` + + `关联订阅 ${linked.subscription_id || "-"} · 支付会话 ${linked.checkout_session_id || "-"}\n` + + `治理个案 ${(linked.governance_case_ids || []).join(" / ") || "-"}\n` + + `世界版本 ${(linked.world_version_ids || []).join(" / ") || "-"}\n` + + `客服问题 ${(linked.support_issue_ids || []).join(" / ") || "-"}\n\n` + + `长线口径:\n` + + `入口 ${authorCapability.entry_mode || "-"} · 目标 ${authorCapability.requested_target_band || "-"} · claim ${authorCapability.claim_safe_band || "-"}\n` + + `Ops ready band ${authorAlignment.ops_release_ready_band || "-"} · alignment ${authorAlignment.aligned === undefined ? "-" : (authorAlignment.aligned ? "yes" : "no")}\n` + + `Readiness ${((authorCapability.longform_readiness || {}).status) || "-"} · blockers ${(((authorCapability.longform_readiness || {}).blockers) || []).map((item) => item.message || item.key).join(" / ") || "-"}\n\n` + + `摘要:\n` + + `最近时间 ${summary.latest_at ? formatTimestamp(summary.latest_at) : "-"}\n` + + `分类 ${Object.entries(summary.category_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + + `严重度 ${Object.entries(summary.severity_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\n` + + `限制 ${summary.active_restriction_count ?? 0} · 客服问题 ${summary.open_support_issue_count ?? 0} · 账单事件 ${summary.billing_event_count ?? 0} · 重试 ${summary.billing_retry_attempt_count ?? 0}\n\n` + + `推荐路径:\n${recommended}` }) ); if (!bundle.trace_timeline?.length) { - clearNode(els.opsInvestigationTimeline, "这里会显示统一 trace timeline。"); + clearNode(dom.opsInvestigationTimeline, "这里会显示统一排查时间线。"); } else { bundle.trace_timeline.forEach((item) => { - const card = document.createElement("article"); - card.className = "list-card"; - card.innerHTML = ` -
-

${item.headline || item.trace_id}

- ${item.category || "-"} · ${item.severity || "-"} -
-

${formatTimestamp(item.occurred_at)}\n${item.source_type || "-"} · status ${item.status || "-"}\n${item.summary || "-"}\naccount ${item.account_id || "-"} · world ${item.world_version_id || "-"} · case ${item.case_id || "-"} · session ${item.session_id || "-"}\nobject ${item.object_type || "-"}:${item.object_id || "-"}\nnext ${(item.next_actions || []).join(" / ") || "-"}\nrelated ${(item.related_trace_ids || []).join(" / ") || "-"}\nevidence ${(item.evidence_refs || []).map((ref) => `${ref.label || ref.kind}:${ref.ref_id || "-"}`).join(" / ") || "-"}

- `; + const card = createListCard({ + title: item.headline || item.trace_id, + score: `${item.category || "-"} · ${item.severity || "-"}`, + body: opsSections( + opsSection("时间与来源", [ + opsFieldLine("发生时间", formatTimestamp(item.occurred_at)), + opsFieldLine("来源", item.source_type || "-"), + opsFieldLine("状态", opsStatusLabel(item.status || "-")), + ]), + opsSection("摘要", [ + item.summary || "-", + ]), + opsSection("上下文", [ + opsFieldLine("账户", item.account_id || "-"), + opsFieldLine("世界版本", item.world_version_id || "-"), + opsFieldLine("治理个案", item.case_id || "-"), + opsFieldLine("会话", item.session_id || "-"), + opsFieldLine("对象", opsTargetLabel(item.object_type, item.object_id)), + opsFieldLine("关联轨迹", (item.related_trace_ids || []).join(" / ") || "-"), + ]), + opsSection("下一步", [ + opsList(item.next_actions || []), + ]), + opsSection("证据引用", [ + opsList(item.evidence_refs || [], (ref) => `${ref.label || ref.kind || "证据"} · ${ref.ref_id || "-"}`), + ]) + ) + }); card.addEventListener("click", () => { - if (item.case_id && els.opsGovernanceCaseId) { - els.opsGovernanceCaseId.value = item.case_id; + if (item.case_id && dom.opsGovernanceCaseId) { + dom.opsGovernanceCaseId.value = item.case_id; } - if (item.world_version_id && els.opsInvestigationWorldVersionId) { - els.opsInvestigationWorldVersionId.value = item.world_version_id; + if (item.world_version_id && dom.opsInvestigationWorldVersionId) { + dom.opsInvestigationWorldVersionId.value = item.world_version_id; } - if (item.account_id && els.opsInvestigationAccountId) { - els.opsInvestigationAccountId.value = item.account_id; + if (item.account_id && dom.opsInvestigationAccountId) { + dom.opsInvestigationAccountId.value = item.account_id; } - if (item.source_type === "billing_lifecycle_event" && item.object_id && els.opsBillingEventId) { - els.opsBillingEventId.value = item.object_id; + if (item.source_type === "billing_lifecycle_event" && item.object_id && dom.opsBillingEventId) { + dom.opsBillingEventId.value = item.object_id; } }); - els.opsInvestigationTimeline.appendChild(card); + dom.opsInvestigationTimeline.appendChild(card); }); } if (!bundle.evidence_index?.length) { - clearNode(els.opsInvestigationEvidence, "这里会显示 evidence index。"); + clearNode(dom.opsInvestigationEvidence, "这里会显示证据索引。"); } else { bundle.evidence_index.forEach((item) => { const card = document.createElement("article"); @@ -1433,79 +2249,95 @@ function renderOpsInvestigationSection() {

${item.title || item.evidence_id}

${item.source_type || "-"} -

${item.preview || "-"}\nsource ${item.source_id || "-"}\nlinked ${item.linked_object_type || "-"}:${item.linked_object_id || "-"}

+

${item.preview || "-"}\n来源 ${item.source_id || "-"}\n关联对象 ${item.linked_object_type || "-"}:${item.linked_object_id || "-"}

`; - els.opsInvestigationEvidence.appendChild(card); + dom.opsInvestigationEvidence.appendChild(card); }); } } } function renderOpsLearnedSection() { - clearNode(els.opsEvalMetrics); - if (!appState.opsEvalMetrics) { - clearNode(els.opsEvalMetrics, "这里会显示 pass / rewrite / block、top issues 与质量趋势。"); + clearNode(dom.opsEvalMetrics); + if (!opsState.opsEvalMetrics) { + clearNode(dom.opsEvalMetrics, "这里会显示通过、重写、阻塞、核心问题与质量趋势。"); } else { - const metric = appState.opsEvalMetrics; + const metric = opsState.opsEvalMetrics; + const qualitySummary = opsState.opsQualitySummary || {}; + const commercialization = opsState.opsCommercializationSummary || {}; const continuationSummary = metric.continuation_signal_summary || {}; const topCorrelations = (metric.quality_signal_correlations || []).slice(0, 3); const worldDetails = (metric.continuation_world_details || []).slice(0, 3); const versionDetails = (metric.continuation_version_details || []).slice(0, 4); const accumulation = metric.continuation_sample_accumulation || {}; + const calibration = metric.q03_q09_calibration || {}; + const q03Calibration = calibration.q03 || {}; + const q09Calibration = calibration.q09 || {}; const card = document.createElement("article"); card.className = "list-card"; card.innerHTML = `

当前质量概览

- pass ${(Number(metric.pass_rate || 0) * 100).toFixed(0)}% + 通过 ${(Number(metric.pass_rate || 0) * 100).toFixed(0)}%
-

rewrite ${(Number(metric.rewrite_rate || 0) * 100).toFixed(0)}% · block ${(Number(metric.block_rate || 0) * 100).toFixed(0)}%\n继续相关性 ${Number(metric.online_continuation_correlation || 0).toFixed(2)}\n样本 ${continuationSummary.sample_count ?? 0} · 正样本 ${continuationSummary.positive_count ?? 0} · 负样本 ${continuationSummary.negative_count ?? 0}\nTop correlation ${(topCorrelations || []).map((item) => `${item.metric}=${Number(item.correlation || 0).toFixed(2)}`).join(" / ") || "-"}

+

重写 ${(Number(metric.rewrite_rate || 0) * 100).toFixed(0)}% · 阻塞 ${(Number(metric.block_rate || 0) * 100).toFixed(0)}%\n继续相关性 ${Number(metric.online_continuation_correlation || 0).toFixed(2)}\n样本 ${continuationSummary.sample_count ?? 0} · 正样本 ${continuationSummary.positive_count ?? 0} · 负样本 ${continuationSummary.negative_count ?? 0}\n最高相关性 ${(topCorrelations || []).map((item) => `${item.metric}=${Number(item.correlation || 0).toFixed(2)}`).join(" / ") || "-"}

`; - els.opsEvalMetrics.appendChild(card); + dom.opsEvalMetrics.appendChild(card); const drilldownCard = document.createElement("article"); drilldownCard.className = "list-card"; drilldownCard.innerHTML = `
-

Continuation Drill-down

- ${worldDetails.length} worlds +

继续阅读钻取

+ ${worldDetails.length} 个世界
-

worlds:\n${worldDetails.map((item) => `${item.world_id} · corr ${Number(item.online_continuation_correlation || 0).toFixed(2)} · samples ${item.sample_count ?? 0} · gap ${item.sample_gap ?? 0}\nrate ${(Number(item.continuation_rate || 0) * 100).toFixed(0)}% · action ${item.recommended_action || "-"}`).join("\n\n") || "-"}\n\nversions:\n${versionDetails.map((item) => `${item.world_version_id} · corr ${Number(item.online_continuation_correlation || 0).toFixed(2)} · samples ${item.sample_count ?? 0} · gap ${item.sample_gap ?? 0}`).join("\n") || "-"}

+

世界:\n${worldDetails.map((item) => `${item.world_id} · 相关性 ${Number(item.online_continuation_correlation || 0).toFixed(2)} · 样本 ${item.sample_count ?? 0} · 缺口 ${item.sample_gap ?? 0}\n继续率 ${(Number(item.continuation_rate || 0) * 100).toFixed(0)}% · 建议动作 ${item.recommended_action || "-"}`).join("\n\n") || "-"}\n\n版本:\n${versionDetails.map((item) => `${item.world_version_id} · 相关性 ${Number(item.online_continuation_correlation || 0).toFixed(2)} · 样本 ${item.sample_count ?? 0} · 缺口 ${item.sample_gap ?? 0}`).join("\n") || "-"}

`; - els.opsEvalMetrics.appendChild(drilldownCard); + dom.opsEvalMetrics.appendChild(drilldownCard); const accumulationCard = document.createElement("article"); accumulationCard.className = "list-card"; accumulationCard.innerHTML = `
-

Sample Accumulation

- ${accumulation.worlds_below_target_count ?? 0} worlds pending +

样本积累

+ ${accumulation.worlds_below_target_count ?? 0} 个世界待补样
-

target/world ${accumulation.target_sample_count_per_world ?? 0} · target/version ${accumulation.target_sample_count_per_version ?? 0}\nnegative target ${accumulation.target_negative_samples ?? 0}\nworlds below target ${accumulation.worlds_below_target_count ?? 0} · versions below target ${accumulation.versions_below_target_count ?? 0}\n\nprioritized worlds:\n${(accumulation.prioritized_worlds || []).map((item) => `${item.world_id} · samples ${item.sample_count ?? 0} · negatives ${item.negative_count ?? 0} · gap ${item.sample_gap ?? 0} · ${item.recommended_action || "-"}`).join("\n") || "-"}\n\nprioritized versions:\n${(accumulation.prioritized_versions || []).map((item) => `${item.world_version_id} · samples ${item.sample_count ?? 0} · negatives ${item.negative_count ?? 0} · gap ${item.sample_gap ?? 0}`).join("\n") || "-"}

+

目标 / 世界 ${accumulation.target_sample_count_per_world ?? 0} · 目标 / 版本 ${accumulation.target_sample_count_per_version ?? 0}\n负样本目标 ${accumulation.target_negative_samples ?? 0}\n未达标世界 ${accumulation.worlds_below_target_count ?? 0} · 未达标版本 ${accumulation.versions_below_target_count ?? 0}\n\n优先世界:\n${(accumulation.prioritized_worlds || []).map((item) => `${item.world_id} · 样本 ${item.sample_count ?? 0} · 负样本 ${item.negative_count ?? 0} · 缺口 ${item.sample_gap ?? 0} · ${item.recommended_action || "-"}`).join("\n") || "-"}\n\n优先版本:\n${(accumulation.prioritized_versions || []).map((item) => `${item.world_version_id} · 样本 ${item.sample_count ?? 0} · 负样本 ${item.negative_count ?? 0} · 缺口 ${item.sample_gap ?? 0}`).join("\n") || "-"}

`; - els.opsEvalMetrics.appendChild(accumulationCard); + dom.opsEvalMetrics.appendChild(accumulationCard); + + const calibrationCard = document.createElement("article"); + calibrationCard.className = "list-card"; + calibrationCard.innerHTML = ` +
+

Q03 / Q09 校准

+ ${calibration.coverage_status || "-"} +
+

样本 ${calibration.sample_count ?? 0} · 缺口 ${calibration.sample_gap ?? 0}\nQ03: ${q03Calibration.primary_metric || "-"} = ${q03Calibration.primary_correlation !== null && q03Calibration.primary_correlation !== undefined ? Number(q03Calibration.primary_correlation).toFixed(2) : "-"} · ${q03Calibration.recommendation || "-"}\nQ09: ${q09Calibration.primary_metric || "-"} = ${q09Calibration.primary_correlation !== null && q09Calibration.primary_correlation !== undefined ? Number(q09Calibration.primary_correlation).toFixed(2) : "-"} · ${q09Calibration.recommendation || "-"}

+ `; + dom.opsEvalMetrics.appendChild(calibrationCard); const issuesCard = document.createElement("article"); issuesCard.className = "list-card"; issuesCard.innerHTML = `
-

Top Issues

+

核心问题

${(metric.top_issue_categories || []).length} 类

${(metric.top_issue_categories || []).map((item) => `${item.issue_code} · ${item.count} · ${item.owning_module}\n修复建议:${item.fix_hint}`).join("\n\n") || "暂无 issue 聚合"}

`; - els.opsEvalMetrics.appendChild(issuesCard); + dom.opsEvalMetrics.appendChild(issuesCard); const trendCard = document.createElement("article"); trendCard.className = "list-card"; trendCard.innerHTML = `
-

World Pack 趋势

+

世界包趋势

${(metric.per_world_pack_quality_trend || []).length} 条

${(metric.per_world_pack_quality_trend || []).map((item) => `${item.world_version_id} · avg ${Number(item.avg_score || 0).toFixed(3)}`).join("\n") || "暂无趋势数据"}

`; - els.opsEvalMetrics.appendChild(trendCard); + dom.opsEvalMetrics.appendChild(trendCard); const actionCard = document.createElement("article"); actionCard.className = "list-card"; @@ -1516,151 +2348,505 @@ function renderOpsLearnedSection() {

${(metric.next_actions || []).map((item, index) => `${index + 1}. ${item.issue_code} -> ${item.owning_module}\n${item.fix_hint}`).join("\n\n") || "当前没有额外修复建议。"}

`; - els.opsEvalMetrics.appendChild(actionCard); + dom.opsEvalMetrics.appendChild(actionCard); const learnedCard = document.createElement("article"); learnedCard.className = "list-card"; learnedCard.innerHTML = `
-

Learned Shadow

- ${metric.learned_shadow_summary?.status || "unavailable"} +

学习层影子评估

+ ${metric.learned_shadow_summary?.status || "未就绪"}
-

available ${metric.learned_shadow_summary?.available ? "yes" : "no"}\nagreement ${metric.learned_shadow_summary?.agreement_rate !== null && metric.learned_shadow_summary?.agreement_rate !== undefined ? Number(metric.learned_shadow_summary.agreement_rate).toFixed(3) : "-"}\ntrain ${metric.learned_shadow_summary?.train_count ?? 0} · val ${metric.learned_shadow_summary?.val_count ?? 0} · test ${metric.learned_shadow_summary?.test_count ?? 0}\nwarnings ${(metric.learned_shadow_summary?.warnings || []).join(" / ") || "-"}\nnext ${metric.learned_shadow_summary?.recommended_next_action || "-"}\n\nworld mismatches:\n${(metric.learned_shadow_summary?.top_mismatch_worlds || []).map((item) => `${item.world_id}=${Number(item.value ?? item.count ?? 0).toFixed(3)}`).join("\n") || "-"}\n\nissue mismatches:\n${(metric.learned_shadow_summary?.top_mismatch_issue_codes || []).map((item) => `${item.issue_code || item.key}=${Number(item.value ?? item.count ?? 0).toFixed(3)}`).join("\n") || "-"}

+

是否可用 ${metric.learned_shadow_summary?.available ? "是" : "否"}\n一致率 ${metric.learned_shadow_summary?.agreement_rate !== null && metric.learned_shadow_summary?.agreement_rate !== undefined ? Number(metric.learned_shadow_summary.agreement_rate).toFixed(3) : "-"}\n训练 ${metric.learned_shadow_summary?.train_count ?? 0} · 验证 ${metric.learned_shadow_summary?.val_count ?? 0} · 测试 ${metric.learned_shadow_summary?.test_count ?? 0}\n告警 ${(metric.learned_shadow_summary?.warnings || []).join(" / ") || "-"}\n下一步 ${metric.learned_shadow_summary?.recommended_next_action || "-"}\n\n世界分歧:\n${(metric.learned_shadow_summary?.top_mismatch_worlds || []).map((item) => `${item.world_id}=${Number(item.value ?? item.count ?? 0).toFixed(3)}`).join("\n") || "-"}\n\n问题分歧:\n${(metric.learned_shadow_summary?.top_mismatch_issue_codes || []).map((item) => `${item.issue_code || item.key}=${Number(item.value ?? item.count ?? 0).toFixed(3)}`).join("\n") || "-"}

`; - els.opsEvalMetrics.appendChild(learnedCard); + dom.opsEvalMetrics.appendChild(learnedCard); const rerankerCard = document.createElement("article"); rerankerCard.className = "list-card"; rerankerCard.innerHTML = `
-

Reranker Shadow

- ${metric.learned_reranker_shadow_summary?.status || "unavailable"} +

学习层影子重排

+ ${metric.learned_reranker_shadow_summary?.status || "未就绪"}
-

available ${metric.learned_reranker_shadow_summary?.available ? "yes" : "no"}\ntrain ${metric.learned_reranker_shadow_summary?.train_count ?? 0} · val ${metric.learned_reranker_shadow_summary?.val_count ?? 0} · test ${metric.learned_reranker_shadow_summary?.test_count ?? 0}\nwarnings ${(metric.learned_reranker_shadow_summary?.warnings || []).join(" / ") || "-"}\nnext ${metric.learned_reranker_shadow_summary?.recommended_next_action || "-"}\n\nworld accuracy:\n${Object.entries(metric.learned_reranker_shadow_summary?.per_world_accuracy || {}).map(([worldId, value]) => `${worldId}=${Number(value).toFixed(3)}`).join("\n") || "-"}\n\nissue error:\n${Object.entries(metric.learned_reranker_shadow_summary?.per_issue_code_error_rate || {}).map(([issueCode, value]) => `${issueCode}=${Number(value).toFixed(3)}`).join("\n") || "-"}\n\nlow coverage:\n${(metric.learned_reranker_shadow_summary?.low_pair_coverage_worlds || []).map((item) => `${item.world_id}=${item.count}`).join("\n") || "-"}

+

是否可用 ${metric.learned_reranker_shadow_summary?.available ? "是" : "否"}\n训练 ${metric.learned_reranker_shadow_summary?.train_count ?? 0} · 验证 ${metric.learned_reranker_shadow_summary?.val_count ?? 0} · 测试 ${metric.learned_reranker_shadow_summary?.test_count ?? 0}\n告警 ${(metric.learned_reranker_shadow_summary?.warnings || []).join(" / ") || "-"}\n下一步 ${metric.learned_reranker_shadow_summary?.recommended_next_action || "-"}\n\n世界准确率:\n${Object.entries(metric.learned_reranker_shadow_summary?.per_world_accuracy || {}).map(([worldId, value]) => `${worldId}=${Number(value).toFixed(3)}`).join("\n") || "-"}\n\n问题错误率:\n${Object.entries(metric.learned_reranker_shadow_summary?.per_issue_code_error_rate || {}).map(([issueCode, value]) => `${issueCode}=${Number(value).toFixed(3)}`).join("\n") || "-"}\n\n低覆盖世界:\n${(metric.learned_reranker_shadow_summary?.low_pair_coverage_worlds || []).map((item) => `${item.world_id}=${item.count}`).join("\n") || "-"}

`; - els.opsEvalMetrics.appendChild(rerankerCard); + dom.opsEvalMetrics.appendChild(rerankerCard); + + if (qualitySummary.summary) { + const canonicalCard = document.createElement("article"); + canonicalCard.className = "list-card"; + canonicalCard.innerHTML = ` +
+

Canonical 质量摘要

+ ${qualitySummary.summary.open_review_case_count ?? 0} open +
+

事件 ${qualitySummary.summary.event_count ?? 0} · 案例 ${qualitySummary.summary.review_case_count ?? 0}\nblocked ${qualitySummary.summary.blocked_event_count ?? 0} · review_required ${qualitySummary.summary.review_required_event_count ?? 0}\n反馈 ${qualitySummary.summary.feedback_item_count ?? 0} · 重试 ${qualitySummary.summary.retry_signal_count ?? 0}\n原因 ${(qualitySummary.summary.top_reason_codes || []).map((item) => `${item.reason_code}=${item.count}`).join(" / ") || "-"}\n反馈类型 ${(qualitySummary.summary.top_feedback_types || []).map((item) => `${item.feedback_type}=${item.count}`).join(" / ") || "-"}

+ `; + dom.opsEvalMetrics.appendChild(canonicalCard); + } + + if (qualitySummary.groundedness_summary) { + const groundingCard = document.createElement("article"); + groundingCard.className = "list-card"; + groundingCard.innerHTML = ` +
+

Groundedness Summary

+ ${Number(qualitySummary.groundedness_summary.pass_rate || 0).toFixed(3)} +
+

pass rate ${Number(qualitySummary.groundedness_summary.pass_rate || 0).toFixed(3)}\nweak ${qualitySummary.groundedness_summary.weak_count ?? 0} · failed ${qualitySummary.groundedness_summary.failed_count ?? 0}\nunsupported claims ${qualitySummary.groundedness_summary.unsupported_claim_count ?? 0}

+ `; + dom.opsEvalMetrics.appendChild(groundingCard); + } + + if (qualitySummary.feedback_summary) { + const feedbackCard = document.createElement("article"); + feedbackCard.className = "list-card"; + feedbackCard.innerHTML = ` +
+

Feedback Summary

+ ${qualitySummary.feedback_summary.thumbs_up_count ?? 0}/${qualitySummary.feedback_summary.thumbs_down_count ?? 0} +
+

thumbs up ${qualitySummary.feedback_summary.thumbs_up_count ?? 0} · thumbs down ${qualitySummary.feedback_summary.thumbs_down_count ?? 0}\nexplicit ${(qualitySummary.feedback_summary.explicit_vs_implicit || {}).explicit ?? 0} · implicit ${(qualitySummary.feedback_summary.explicit_vs_implicit || {}).implicit ?? 0}\nreason codes ${(qualitySummary.feedback_summary.top_reason_codes || []).map((item) => `${item.reason_code}=${item.count}`).join(" / ") || "-"}

+ `; + dom.opsEvalMetrics.appendChild(feedbackCard); + } + + if (qualitySummary.review_pressure) { + const reviewPressureCard = document.createElement("article"); + reviewPressureCard.className = "list-card"; + reviewPressureCard.innerHTML = ` +
+

Review Pressure

+ ${qualitySummary.review_pressure.unresolved_review_cases ?? 0} +
+

new ${qualitySummary.review_pressure.new_review_cases ?? 0} · unresolved ${qualitySummary.review_pressure.unresolved_review_cases ?? 0}\nreasons ${(qualitySummary.review_pressure.top_review_reasons || []).map((item) => `${item.reason_code}=${item.count}`).join(" / ") || "-"}

+ `; + dom.opsEvalMetrics.appendChild(reviewPressureCard); + } + + if (qualitySummary.quality_trend) { + const trendCardV1 = document.createElement("article"); + trendCardV1.className = "list-card"; + trendCardV1.innerHTML = ` +
+

Quality Trend

+ ${(qualitySummary.quality_trend.score_trend || []).length} traces +
+

score ${(qualitySummary.quality_trend.score_trend || []).map((item) => `${item.trace_id}:${item.overall_score}`).join(" / ") || "-"}\nveto ${(qualitySummary.quality_trend.veto_trend || []).map((item) => `${item.trace_id}:${item.veto ? "yes" : "no"}`).join(" / ") || "-"}\nguard ${(qualitySummary.quality_trend.guard_failed_trend || []).map((item) => `${item.trace_id}:${item.status}`).join(" / ") || "-"}

+ `; + dom.opsEvalMetrics.appendChild(trendCardV1); + } + + if (commercialization.invoice_preview_totals) { + const commercialCard = document.createElement("article"); + commercialCard.className = "list-card"; + commercialCard.innerHTML = ` +
+

Commercial Ops Summary

+ ${(commercialization.invoice_preview_totals || {}).preview_count ?? 0} previews +
+

invoice subtotal ${Number((commercialization.invoice_preview_totals || {}).subtotal_amount_usd || 0).toFixed(2)} · due ${Number((commercialization.invoice_preview_totals || {}).total_due_usd || 0).toFixed(2)}\nunpaid ${(commercialization.financial_status_totals || {}).unpaid_count ?? 0} · disputed ${(commercialization.financial_status_totals || {}).disputed_count ?? 0} · credited ${(commercialization.financial_status_totals || {}).credited_count ?? 0}\noverage ${(commercialization.overage_totals || {}).active_flag_count ?? 0} · renewal_due ${(commercialization.renewal_due_accounts || {}).count ?? 0} · churn ${(commercialization.churn_risk_accounts || {}).count ?? 0}\ndunning ${(commercialization.dunning_runs || {}).count ?? 0} · pilot_ready ${(commercialization.pilot_conversion || {}).ready_count ?? 0} · expansion ${(commercialization.expansion_candidates || {}).recommended_count ?? 0}\npartner heatmap lifecycle ${(commercialization.partner_readiness_heatmap || {}).lifecycle_counts ? Object.entries((commercialization.partner_readiness_heatmap || {}).lifecycle_counts).map(([key, value]) => `${key}=${value}`).join(" / ") : "-"}\nsupport ${(commercialization.support_backlog || {}).count ?? 0} · disputes ${(commercialization.dispute_backlog || {}).count ?? 0}

+ `; + dom.opsEvalMetrics.appendChild(commercialCard); + } + + if (commercialization.production_signoff) { + const signoff = commercialization.production_signoff || {}; + const detail = opsState.opsProductionSignoffDetail || {}; + const nextDue = signoff.next_due_item || {}; + const cutover = signoff.planned_cutover_window || {}; + const signoffCard = document.createElement("article"); + signoffCard.className = "list-card"; + signoffCard.innerHTML = ` +
+

Production Signoff

+ ${signoff.status || "-"} +
+

launch ${(signoff.launch_label || "-")} · signoff ${(signoff.signoff_id || "-")}\npending ${signoff.pending_item_count ?? 0} · approved ${signoff.approved_item_count ?? 0} · rejected ${signoff.rejected_item_count ?? 0}\nnext due ${(nextDue.item_code || "-")} · owner ${(nextDue.owner_role || "-")} · due ${(nextDue.due_at ? formatTimestamp(nextDue.due_at) : "-")}\ncutover ${(cutover.launch_wave || "-")} · ${(cutover.target_environment || "-")} · ${(cutover.status || "-")}\nexport ${(detail.export_refs || {}).record_dir || "-"}

+ `; + dom.opsEvalMetrics.appendChild(signoffCard); + } + + if (commercialization.production_signoff_action_board) { + const board = commercialization.production_signoff_action_board || {}; + const boardCard = document.createElement("article"); + boardCard.className = "list-card"; + boardCard.innerHTML = ` +
+

Production Signoff Action Board

+ ${board.status || "-"} +
+

pending ${(board.pending_item_count ?? 0)} · approved ${(board.approved_item_count ?? 0)} · rejected ${(board.rejected_item_count ?? 0)} · overdue ${(board.overdue_count ?? 0)}\nblockers ${(board.blocker_counts && Object.entries(board.blocker_counts).map(([key, value]) => `${key}=${value}`).join(" / ")) || "-"}\nnext due ${((board.next_due_item || {}).item_code || "-")} · export ${((board.export_refs || {}).record_dir || "-")}

+ `; + dom.opsEvalMetrics.appendChild(boardCard); + } + + if (commercialization.production_acceptance) { + const acceptance = commercialization.production_acceptance || {}; + const acceptanceCard = document.createElement("article"); + acceptanceCard.className = "list-card"; + acceptanceCard.innerHTML = ` +
+

Production Acceptance

+ ${acceptance.acceptance_record_count ?? 0} records +
+

go_live_ready ${(acceptance.go_live_ready_count ?? 0)} · blocked ${(acceptance.blocked_go_live_count ?? 0)}\nlaunch waves ${(acceptance.launch_wave_count ?? 0)}

+ `; + dom.opsEvalMetrics.appendChild(acceptanceCard); + } + + if (commercialization.launch_week_ops_pack) { + const launchOpsPack = commercialization.launch_week_ops_pack || {}; + const launchOpsCard = document.createElement("article"); + launchOpsCard.className = "list-card"; + launchOpsCard.innerHTML = ` +
+

Launch Week Ops Pack

+ ${launchOpsPack.document_count ?? 0} docs +
+

bundle ${(launchOpsPack.bundle_id || "-")}\nunresolved manual signoff ${(launchOpsPack.unresolved_manual_signoff_count ?? 0)} · launch alerts ${(launchOpsPack.launch_week_alert_count ?? 0)} · waves ${(launchOpsPack.launch_wave_count ?? 0)}\nrefs ${((launchOpsPack.refs || {}).doc_refs || []).join(" / ") || "-"}

+ `; + dom.opsEvalMetrics.appendChild(launchOpsCard); + } + + if (commercialization.launch_handshake_pack) { + const handshakePack = commercialization.launch_handshake_pack || {}; + const handshakeCard = document.createElement("article"); + handshakeCard.className = "list-card"; + handshakeCard.innerHTML = ` +
+

Legal & Human-Ops Handshake Pack

+ ${handshakePack.document_count ?? 0} docs +
+

bundle ${(handshakePack.bundle_id || "-")}\nunresolved manual signoff ${(handshakePack.unresolved_manual_signoff_count ?? 0)} · org-ready deps ${(handshakePack.org_ready_dependency_count ?? 0)} · contact gaps ${(handshakePack.contact_gap_count ?? 0)}\nlaunch waves ${(handshakePack.launch_wave_count ?? 0)} · launch customers ${(handshakePack.launch_customer_count ?? 0)}\nrefs ${((handshakePack.refs || {}).doc_refs || []).join(" / ") || "-"}

+ `; + dom.opsEvalMetrics.appendChild(handshakeCard); + } + + if (commercialization.human_signoff_closure_summary) { + const closure = commercialization.human_signoff_closure_summary || {}; + const closureCard = document.createElement("article"); + closureCard.className = "list-card"; + closureCard.innerHTML = ` +
+

Human Signoff Closure

+ ${closure.item_count ?? 0} items +
+

signoff ${(closure.signoff_id || "-")}\nblockers ${(closure.blocker_counts && Object.entries(closure.blocker_counts).map(([key, value]) => `${key}=${value}`).join(" / ")) || "-"}\nowners ${(closure.owner_counts && Object.entries(closure.owner_counts).map(([key, value]) => `${key}=${Object.values(value || {}).reduce((acc, item) => acc + Number(item || 0), 0)}`).join(" / ")) || "-"}

+ `; + dom.opsEvalMetrics.appendChild(closureCard); + } + + if (commercialization.launch_command_center_summary) { + const command = commercialization.launch_command_center_summary || {}; + const commandCard = document.createElement("article"); + commandCard.className = "list-card"; + commandCard.innerHTML = ` +
+

Launch Week Command Center

+ ${command.watchlist_count ?? 0} customers +
+

launch waves ${(command.launch_wave_count ?? 0)} · revenue protection alerts ${(command.revenue_protection_alert_count ?? 0)}\nlatest preflight ${command.latest_preflight_by_wave ? Object.entries(command.latest_preflight_by_wave).map(([key, value]) => `${key}:${value.status}/${value.go_no_go}`).join(" / ") : "-"}

+ `; + dom.opsEvalMetrics.appendChild(commandCard); + } + + if (commercialization.wave_activation_summary) { + const activation = commercialization.wave_activation_summary || {}; + const activationCard = document.createElement("article"); + activationCard.className = "list-card"; + activationCard.innerHTML = ` +
+

Wave Activation

+ ${activation.wave_count ?? 0} waves +
+

active ${(activation.active_count ?? 0)} · armed ${(activation.armed_count ?? 0)} · activation_ready ${(activation.activation_ready_count ?? 0)} · blocked ${(activation.blocked_count ?? 0)}

+ `; + dom.opsEvalMetrics.appendChild(activationCard); + } + + if (commercialization.go_live_day_summary) { + const goLiveDay = commercialization.go_live_day_summary || {}; + const goLiveDayCard = document.createElement("article"); + goLiveDayCard.className = "list-card"; + goLiveDayCard.innerHTML = ` +
+

Go-Live Day Runner

+ ${goLiveDay.run_count ?? 0} runs +
+

latest ${(goLiveDay.latest_run_id || "-")}\nstatus ${(goLiveDay.status_counts && Object.entries(goLiveDay.status_counts).map(([key, value]) => `${key}=${value}`).join(" / ")) || "-"}

+ `; + dom.opsEvalMetrics.appendChild(goLiveDayCard); + } + + if (commercialization.customer_success_summary) { + const success = commercialization.customer_success_summary || {}; + const successCard = document.createElement("article"); + successCard.className = "list-card"; + successCard.innerHTML = ` +
+

Customer Success Snapshot

+ ${success.account_count ?? 0} accounts +
+

bands ${(success.band_counts && Object.entries(success.band_counts).map(([key, value]) => `${key}=${value}`).join(" / ")) || "-"}\nprovisional_30_day ${(success.provisional_30_day_count ?? 0)} · launch waves ${(success.launch_wave_count ?? 0)}

+ `; + dom.opsEvalMetrics.appendChild(successCard); + } + + if (commercialization.launch_week_guard_summary) { + const guard = commercialization.launch_week_guard_summary || {}; + const guardCard = document.createElement("article"); + guardCard.className = "list-card"; + guardCard.innerHTML = ` +
+

First 7 Days Guard

+ ${guard.run_count ?? 0} runs +
+

latest ${(guard.latest_run_id || "-")}\nreplication-ready ${(guard.ready_count ?? 0)}

+ `; + dom.opsEvalMetrics.appendChild(guardCard); + } + + if (commercialization.launch_ledger_summary) { + const ledger = commercialization.launch_ledger_summary || {}; + const ledgerCard = document.createElement("article"); + ledgerCard.className = "list-card"; + ledgerCard.innerHTML = ` +
+

Production Launch Ledger

+ ${ledger.event_count ?? 0} events +
+

severity ${(ledger.severity_counts && Object.entries(ledger.severity_counts).map(([key, value]) => `${key}=${value}`).join(" / ")) || "-"}\nphase ${(ledger.phase_counts && Object.entries(ledger.phase_counts).map(([key, value]) => `${key}=${value}`).join(" / ")) || "-"}

+ `; + dom.opsEvalMetrics.appendChild(ledgerCard); + } + + if (commercialization.launch_week_alert_pack?.summary) { + const pack = commercialization.launch_week_alert_pack; + const launchCard = document.createElement("article"); + launchCard.className = "list-card"; + launchCard.innerHTML = ` +
+

Launch Week Alert Pack

+ ${pack.summary.alert_count ?? 0} alerts +
+

severity ${(pack.summary.by_severity && Object.entries(pack.summary.by_severity).map(([key, value]) => `${key}=${value}`).join(" / ")) || "-"}\nowners ${(pack.summary.by_owner_role && Object.entries(pack.summary.by_owner_role).map(([key, value]) => `${key}=${value}`).join(" / ")) || "-"}\nalerts ${(pack.alerts || []).map((item) => `${item.alert_key}:${item.count}/${item.owner_role}`).join(" / ") || "-"}\nrefs ${(pack.alerts || []).flatMap((item) => item.drilldown_refs || []).slice(0, 8).map((ref) => `${ref.kind}:${ref.id}`).join(" / ") || "-"}

+ `; + dom.opsEvalMetrics.appendChild(launchCard); + } } - clearNode(els.opsCrossPackQuality); - if (!appState.opsCrossPackQuality) { - clearNode(els.opsCrossPackQuality, "这里会显示 cross-pack pass rate、top failing packs 与 metric delta。"); + clearNode(dom.opsCrossPackQuality); + if (!opsState.opsCrossPackQuality) { + clearNode(dom.opsCrossPackQuality, "这里会显示跨包通过率、薄弱世界与核心指标变化。"); } else { - const benchmark = appState.opsCrossPackQuality; + const benchmark = opsState.opsCrossPackQuality; const overviewCard = document.createElement("article"); overviewCard.className = "list-card"; overviewCard.innerHTML = `
-

Cross-Pack 概览

+

跨包概览

${formatPercent(benchmark.cross_pack_pass_rate)}
-

覆盖 ${benchmark.worlds?.length || 0} 个 packs\npass rate delta ${benchmark.delta_summary?.cross_pack_pass_rate_delta >= 0 ? "+" : ""}${Number(benchmark.delta_summary?.cross_pack_pass_rate_delta || 0).toFixed(3)}

+

覆盖 ${benchmark.worlds?.length || 0} 个世界包\n通过率变化 ${benchmark.delta_summary?.cross_pack_pass_rate_delta >= 0 ? "+" : ""}${Number(benchmark.delta_summary?.cross_pack_pass_rate_delta || 0).toFixed(3)}

`; - els.opsCrossPackQuality.appendChild(overviewCard); + dom.opsCrossPackQuality.appendChild(overviewCard); const failingCard = document.createElement("article"); failingCard.className = "list-card"; failingCard.innerHTML = `
-

Top Failing Packs

+

薄弱世界

${(benchmark.top_failing_packs || []).length} 个
-

${(benchmark.top_failing_packs || []).map((item) => `${item.world_id}\npass ${formatPercent(item.pass_rate)} · block ${formatPercent(item.block_rate)}\n主问题:${(item.top_issue_categories || []).map((issue) => issue.issue_code).join(" / ") || "-"}\n最弱维度:${(item.weakest_dimensions || []).map((dimension) => `${dimension.name}=${Number(dimension.value || 0).toFixed(3)}`).join(" / ") || "-"}\n建议目标:${item.recommended_target || "-"}\nvoice ${Number(item.voice_separation_score || 0).toFixed(2)} · action ${Number(item.emotion_action_specificity || 0).toFixed(2)} · leak ${Number(item.prose_leak_rate || 0).toFixed(3)}`).join("\n\n") || "暂无 cross-pack 弱项。"}

+

${(benchmark.top_failing_packs || []).map((item) => `${item.world_id}\n通过率 ${formatPercent(item.pass_rate)} · 阻塞率 ${formatPercent(item.block_rate)}\n主问题:${(item.top_issue_categories || []).map((issue) => issue.issue_code).join(" / ") || "-"}\n最弱维度:${(item.weakest_dimensions || []).map((dimension) => `${dimension.name}=${Number(dimension.value || 0).toFixed(3)}`).join(" / ") || "-"}\n建议目标:${item.recommended_target || "-"}\n声音 ${Number(item.voice_separation_score || 0).toFixed(2)} · 动作 ${Number(item.emotion_action_specificity || 0).toFixed(2)} · 泄漏 ${Number(item.prose_leak_rate || 0).toFixed(3)}`).join("\n\n") || "暂无跨包薄弱项。"}

`; - els.opsCrossPackQuality.appendChild(failingCard); + dom.opsCrossPackQuality.appendChild(failingCard); const deltaCard = document.createElement("article"); deltaCard.className = "list-card"; deltaCard.innerHTML = `
-

Metric Deltas

+

指标变化

${(benchmark.delta_summary?.regressions || []).length} 个回退

${(benchmark.delta_summary?.regressions || []).map((item) => `${item.world_id}\n${item.metrics.join(" / ")}`).join("\n\n") || "当前没有跨 Pack 指标回退。"}

`; - els.opsCrossPackQuality.appendChild(deltaCard); + dom.opsCrossPackQuality.appendChild(deltaCard); const diagnosisCard = document.createElement("article"); diagnosisCard.className = "list-card"; diagnosisCard.innerHTML = `
-

Per-Pack Diagnosis

+

逐包诊断

${(benchmark.worlds || []).length} 个

${(benchmark.worlds || []).map((item) => `${item.world_id}\n主问题:${item.issue_summary?.dominant_issue || "-"}\n最弱维度:${(item.issue_summary?.weakest_dimensions || []).map((dimension) => `${dimension.name}=${Number(dimension.value || 0).toFixed(3)}`).join(" / ") || "-"}\n建议目标:${item.issue_summary?.recommended_target || "-"}`).join("\n\n") || "暂无诊断数据。"}

`; - els.opsCrossPackQuality.appendChild(diagnosisCard); + dom.opsCrossPackQuality.appendChild(diagnosisCard); + + const batchValidation = benchmark.strategy_bundle_batch_validation || {}; + const batchOverviewCard = document.createElement("article"); + batchOverviewCard.className = "list-card"; + batchOverviewCard.innerHTML = ` +
+

策略包批量验证概览

+ ${batchValidation.available ? (batchValidation.decision || "-") : "未运行"} +
+

策略包 ${(batchValidation.strategy_bundle_label || "-")}${batchValidation.strategy_bundle_id ? ` (${batchValidation.strategy_bundle_id})` : ""}\n已验证 ${batchValidation.validated_world_count ?? 0} 个 weakest packs · 有效率 ${Number(batchValidation.effectiveness_rate || 0).toFixed(3)}\n兼容世界:${(batchValidation.compatible_world_ids || []).join(" / ") || "-"}\n跳过世界:${(batchValidation.skipped_worlds || []).map((item) => `${item.world_id || "-"}(${item.reason || "-"})`).join(" / ") || (batchValidation.available ? "-" : (batchValidation.decision_reason || "未开启策略包批量验证"))}\n结论:${batchValidation.decision || "-"} · 原因:${batchValidation.decision_reason || "-"}

+ `; + dom.opsCrossPackQuality.appendChild(batchOverviewCard); + + const batchAttributionCard = document.createElement("article"); + batchAttributionCard.className = "list-card"; + batchAttributionCard.innerHTML = ` +
+

策略包执行归因

+ ${batchValidation.available ? `${batchValidation.validated_world_count || 0} 个` : "无数据"} +
+

overall status:${Object.entries(batchValidation.aggregated_result_attribution?.overall_status_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\nstop decisions:${Object.entries(batchValidation.aggregated_result_attribution?.stop_decision_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\nready for validation:${batchValidation.aggregated_result_attribution?.ready_for_validation_count ?? 0}\n\n逐包结果:\n${(batchValidation.validated_worlds || []).map((item) => `${item.world_id}\n窗口 ${item.window_label || "-"} · issues ${(item.issue_codes || []).join(" / ") || "-"}\n状态 ${item.result_attribution?.overall_status || "-"} · ready ${item.ready_for_validation ? "yes" : "no"} · stop ${item.stop_decision?.decision || "-"}`).join("\n\n") || (batchValidation.available ? "暂无逐包执行结果。" : "未开启策略包批量验证。")}\n\n调整目标:\n${(batchValidation.adaptation_targets || []).map((item) => `${item.kind}:${item.name}=${item.count}`).join("\n") || "-"}

+ `; + dom.opsCrossPackQuality.appendChild(batchAttributionCard); + + const batchHistory = benchmark.strategy_bundle_batch_validation_history || {}; + const batchTrend = benchmark.strategy_bundle_batch_validation_trend || {}; + const batchTrendCard = document.createElement("article"); + batchTrendCard.className = "list-card"; + batchTrendCard.innerHTML = ` +
+

策略包历史趋势

+ ${batchTrend.trend_status || "insufficient_history"} +
+

策略包 ${(batchTrend.strategy_bundle_id || batchValidation.strategy_bundle_id || "-")}\n趋势原因 ${batchTrend.trend_reason || "-"}\n最新结论 ${batchTrend.latest_decision || "-"} · 最新有效率 ${Number(batchTrend.latest_effectiveness_rate || 0).toFixed(3)}\n有效率变化 ${Number(batchTrend.delta_effectiveness_rate || 0).toFixed(3)} · 建议淘汰 ${batchTrend.retire_recommended ? "yes" : "no"}\n最近有效 run ${batchTrend.recent_run_count ?? 0}\n\n最近几轮:\n${(batchHistory.entries || []).map((item) => `${item.generated_at || "-"}\n${item.decision || "-"} · effectiveness ${Number(item.effectiveness_rate || 0).toFixed(3)} · worlds ${item.validated_world_count ?? 0}`).join("\n\n") || "暂无已保存的策略包批量验证历史。"}

+ `; + dom.opsCrossPackQuality.appendChild(batchTrendCard); } - clearNode(els.opsLearnedDashboard); - clearNode(els.opsLearnedImpact); - clearNode(els.opsLearnedCadence); - clearNode(els.opsLearnedAssistedGate); - clearNode(els.opsLearnedAssistedRerank); - clearNode(els.opsLearnedReviewQuality); - clearNode(els.opsLearnedTraining); - clearNode(els.opsLearnedEvidence); - if (!appState.opsLearnedDashboard) { - clearNode(els.opsLearnedDashboard, "这里会显示 evaluator / reranker 的统一 learned summary。"); - clearNode(els.opsLearnedImpact, "这里会显示 evaluator / reranker 的 learned impact summary、retention proxy 与 monetization proxy。"); - clearNode(els.opsLearnedCadence, "这里会显示 evaluator / reranker 当前处于 collect data、train、validate、promotion 还是 activate 阶段。"); - clearNode(els.opsLearnedAssistedGate, "这里会显示 assisted gate experiment 的 config、guardrails、recent decisions 与 rollback 条件。"); - clearNode(els.opsLearnedAssistedRerank, "这里会显示 assisted rerank experiment 的 config、guardrails、recent decisions 与 rollback 条件。"); - clearNode(els.opsLearnedReviewQuality, "这里会显示 human review coverage、reviewer diversity、样本质量告警与高覆盖补样 backlog。"); - clearNode(els.opsLearnedTraining, "这里会显示最近一次 learned training automation 结果。"); - clearNode(els.opsLearnedEvidence, "这里会显示 evaluator / reranker 的 promotion evidence pack 摘要。"); + clearNode(dom.opsLearnedDashboard); + clearNode(dom.opsLearnedImpact); + clearNode(dom.opsLearnedCadence); + clearNode(dom.opsLearnedAssistedGate); + clearNode(dom.opsLearnedAssistedRerank); + clearNode(dom.opsLearnedReviewQuality); + clearNode(dom.opsLearnedTraining); + clearNode(dom.opsLearnedEvidence); + if (!opsState.opsLearnedDashboard) { + clearNode(dom.opsLearnedDashboard, "这里会显示评估器和重排器的统一学习层摘要。"); + clearNode(dom.opsLearnedImpact, "这里会显示评估器和重排器的学习层影响摘要、留存代理与付费代理。"); + clearNode(dom.opsLearnedCadence, "这里会显示评估器和重排器当前处于补数据、训练、验证、晋升还是全量启用阶段。"); + clearNode(dom.opsLearnedAssistedGate, "这里会显示辅助门控实验的配置、护栏、最近决策与回滚条件。"); + clearNode(dom.opsLearnedAssistedRerank, "这里会显示辅助重排实验的配置、护栏、最近决策与回滚条件。"); + clearNode(dom.opsLearnedReviewQuality, "这里会显示人工审阅覆盖、审阅人分布、样本质量告警与高覆盖补样待办。"); + clearNode(dom.opsLearnedTraining, "这里会显示最近一次学习层训练自动化结果。"); + clearNode(dom.opsLearnedEvidence, "这里会显示评估器和重排器的发布证据包摘要。"); } else { - const dashboard = appState.opsLearnedDashboard; - const learnedImpact = appState.opsLearnedImpact || {}; - const learnedCadence = appState.opsLearnedCadence || {}; - const assistedGate = appState.opsLearnedAssistedGate || {}; - const assistedRerank = appState.opsLearnedAssistedRerank || {}; + const dashboard = opsState.opsLearnedDashboard; + const learnedImpact = opsState.opsLearnedImpact || {}; + const learnedCadence = opsState.opsLearnedCadence || {}; + const assistedGate = opsState.opsLearnedAssistedGate || {}; + const assistedRerank = opsState.opsLearnedAssistedRerank || {}; const overviewCard = document.createElement("article"); overviewCard.className = "list-card"; overviewCard.innerHTML = `
-

Unified Learned Dashboard

+

学习层统一总览

${dashboard.recommended_next_focus || "-"}
-

generated ${formatTimestamp(dashboard.generated_at)}\nwarnings ${(dashboard.warnings || []).join(" / ") || "-"}\nshared weak worlds ${(dashboard.shared_weak_worlds || []).join(" / ") || "-"}\nshared weak issues ${(dashboard.shared_weak_issue_codes || []).join(" / ") || "-"}\nnext ${dashboard.recommended_next_focus || "-"}

+

${opsSections( + opsSection("生成信息", [ + opsFieldLine("生成时间", formatTimestamp(dashboard.generated_at)), + opsFieldLine("建议关注", dashboard.recommended_next_focus || "-"), + ]), + opsSection("共同薄弱项", [ + opsFieldLine("共同薄弱世界", (dashboard.shared_weak_worlds || []).map((worldId) => opsWorldLabel(worldId)).join(" / ") || "-"), + opsFieldLine("共同薄弱问题", opsIssueCodeList(dashboard.shared_weak_issue_codes || [])), + opsFieldLine("告警", (dashboard.warnings || []).join(" / ") || "-"), + ]) + )}

`; - els.opsLearnedDashboard.appendChild(overviewCard); + dom.opsLearnedDashboard.appendChild(overviewCard); if (!learnedImpact.track_summaries?.length) { - clearNode(els.opsLearnedImpact, "这里会显示 evaluator / reranker 的 learned impact summary、retention proxy 与 monetization proxy。"); + clearNode(dom.opsLearnedImpact, "这里会显示评估器和重排器的学习层影响摘要、留存代理与付费代理。"); } else { const trackCard = document.createElement("article"); trackCard.className = "list-card"; trackCard.innerHTML = `
-

Track Impact Summary

+

轨道影响摘要

${learnedImpact.track_summaries.length} tracks
-

${(learnedImpact.track_summaries || []).map((item) => `${item.track}\nstatus ${item.impact_status} · sufficiency ${item.evidence_sufficiency}\nsamples ${item.sample_count ?? 0} · worlds ${item.world_coverage_count ?? 0} · issues ${item.issue_coverage_count ?? 0}\ncontinuation ${item.continuation_correlation !== null && item.continuation_correlation !== undefined ? Number(item.continuation_correlation).toFixed(2) : "-"} · monetization ${item.monetization_correlation !== null && item.monetization_correlation !== undefined ? Number(item.monetization_correlation).toFixed(2) : "-"}\nshadow ${item.shadow_agreement_or_accuracy !== null && item.shadow_agreement_or_accuracy !== undefined ? Number(item.shadow_agreement_or_accuracy).toFixed(2) : "-"} · next ${item.recommended_next_action || "-"}`).join("\n\n") || "-"}

+

${opsParagraphList(learnedImpact.track_summaries || [], (item) => + `${opsTrackLabel(item.track)}\n` + + `${opsFieldLine("影响状态", opsStatusLabel(item.impact_status))} · ${opsFieldLine("证据充分度", item.evidence_sufficiency || "-")}\n` + + `${opsFieldLine("样本数", item.sample_count ?? 0)} · ${opsFieldLine("覆盖世界", item.world_coverage_count ?? 0)} · ${opsFieldLine("覆盖问题", item.issue_coverage_count ?? 0)}\n` + + `${opsFieldLine("继续阅读相关性", item.continuation_correlation !== null && item.continuation_correlation !== undefined ? Number(item.continuation_correlation).toFixed(2) : "-")} · ${opsFieldLine("付费相关性", item.monetization_correlation !== null && item.monetization_correlation !== undefined ? Number(item.monetization_correlation).toFixed(2) : "-")}\n` + + `${opsFieldLine("影子指标", item.shadow_agreement_or_accuracy !== null && item.shadow_agreement_or_accuracy !== undefined ? Number(item.shadow_agreement_or_accuracy).toFixed(2) : "-")} · ${opsFieldLine("建议动作", item.recommended_next_action || "-")}` + )}

`; - els.opsLearnedImpact.appendChild(trackCard); + dom.opsLearnedImpact.appendChild(trackCard); const proxyCard = document.createElement("article"); proxyCard.className = "list-card"; proxyCard.innerHTML = `
-

Retention / Monetization Proxies

+

留存 / 付费代理指标

${Number(learnedImpact.retention_proxies?.online_continuation_correlation || 0).toFixed(2)}
-

continuation samples ${learnedImpact.retention_proxies?.continuation_signal_summary?.sample_count ?? 0} · positive ${learnedImpact.retention_proxies?.continuation_signal_summary?.positive_count ?? 0} · negative ${learnedImpact.retention_proxies?.continuation_signal_summary?.negative_count ?? 0}\ncheckout ${learnedImpact.monetization_proxies?.checkout_started_count ?? 0} · activated ${learnedImpact.monetization_proxies?.subscription_activated_count ?? 0} · paywall ${learnedImpact.monetization_proxies?.payment_required_count ?? 0}\nstory credits ${learnedImpact.monetization_proxies?.story_credit_consumed_count ?? 0} · studio credits ${learnedImpact.monetization_proxies?.studio_credit_consumed_count ?? 0}\nquality->checkout ${learnedImpact.monetization_proxies?.quality_to_checkout_correlation !== null && learnedImpact.monetization_proxies?.quality_to_checkout_correlation !== undefined ? Number(learnedImpact.monetization_proxies.quality_to_checkout_correlation).toFixed(2) : "-"}\nquality->subscription ${learnedImpact.monetization_proxies?.quality_to_subscription_correlation !== null && learnedImpact.monetization_proxies?.quality_to_subscription_correlation !== undefined ? Number(learnedImpact.monetization_proxies.quality_to_subscription_correlation).toFixed(2) : "-"}\nquality->paywall ${learnedImpact.monetization_proxies?.quality_to_paywall_correlation !== null && learnedImpact.monetization_proxies?.quality_to_paywall_correlation !== undefined ? Number(learnedImpact.monetization_proxies.quality_to_paywall_correlation).toFixed(2) : "-"}

+

${opsSections( + opsSection("继续阅读信号", [ + opsFieldLine("样本数", learnedImpact.retention_proxies?.continuation_signal_summary?.sample_count ?? 0), + opsFieldLine("正样本", learnedImpact.retention_proxies?.continuation_signal_summary?.positive_count ?? 0), + opsFieldLine("负样本", learnedImpact.retention_proxies?.continuation_signal_summary?.negative_count ?? 0), + ]), + opsSection("付费信号", [ + opsFieldLine("发起支付", learnedImpact.monetization_proxies?.checkout_started_count ?? 0), + opsFieldLine("订阅生效", learnedImpact.monetization_proxies?.subscription_activated_count ?? 0), + opsFieldLine("出现挡板", learnedImpact.monetization_proxies?.payment_required_count ?? 0), + opsFieldLine("故事点数消耗", learnedImpact.monetization_proxies?.story_credit_consumed_count ?? 0), + opsFieldLine("创作点数消耗", learnedImpact.monetization_proxies?.studio_credit_consumed_count ?? 0), + ]), + opsSection("质量相关性", [ + opsFieldLine("质量→支付", learnedImpact.monetization_proxies?.quality_to_checkout_correlation !== null && learnedImpact.monetization_proxies?.quality_to_checkout_correlation !== undefined ? Number(learnedImpact.monetization_proxies.quality_to_checkout_correlation).toFixed(2) : "-"), + opsFieldLine("质量→订阅", learnedImpact.monetization_proxies?.quality_to_subscription_correlation !== null && learnedImpact.monetization_proxies?.quality_to_subscription_correlation !== undefined ? Number(learnedImpact.monetization_proxies.quality_to_subscription_correlation).toFixed(2) : "-"), + opsFieldLine("质量→挡板", learnedImpact.monetization_proxies?.quality_to_paywall_correlation !== null && learnedImpact.monetization_proxies?.quality_to_paywall_correlation !== undefined ? Number(learnedImpact.monetization_proxies.quality_to_paywall_correlation).toFixed(2) : "-"), + ]) + )}

`; - els.opsLearnedImpact.appendChild(proxyCard); + dom.opsLearnedImpact.appendChild(proxyCard); const experiment = learnedImpact.experiment_summaries?.assisted_gate || {}; const experimentCard = document.createElement("article"); experimentCard.className = "list-card"; experimentCard.innerHTML = `
-

Assisted Gate Impact

+

辅助门控影响

${experiment.impact_status || "-"}
-

mode ${experiment.mode || "-"} · enabled ${experiment.enabled ? "yes" : "no"} · sufficiency ${experiment.evidence_sufficiency || "-"}\ndecisions ${experiment.decision_count ?? 0} · worlds ${experiment.world_coverage_count ?? 0} · in bucket ${experiment.in_bucket_count ?? 0}\nwould block ${experiment.would_block_count ?? 0} · assisted block ${experiment.assisted_block_count ?? 0}\ncontinuation ${experiment.continuation_correlation !== null && experiment.continuation_correlation !== undefined ? Number(experiment.continuation_correlation).toFixed(2) : "-"} · monetization ${experiment.monetization_correlation !== null && experiment.monetization_correlation !== undefined ? Number(experiment.monetization_correlation).toFixed(2) : "-"}\nblock->checkout ${experiment.assisted_block_to_checkout_correlation !== null && experiment.assisted_block_to_checkout_correlation !== undefined ? Number(experiment.assisted_block_to_checkout_correlation).toFixed(2) : "-"} · block->subscription ${experiment.assisted_block_to_subscription_correlation !== null && experiment.assisted_block_to_subscription_correlation !== undefined ? Number(experiment.assisted_block_to_subscription_correlation).toFixed(2) : "-"} · block->paywall ${experiment.assisted_block_to_paywall_correlation !== null && experiment.assisted_block_to_paywall_correlation !== undefined ? Number(experiment.assisted_block_to_paywall_correlation).toFixed(2) : "-"}\nnext ${experiment.recommended_next_action || "-"}

+

${opsSections( + opsSection("实验状态", [ + opsFieldLine("模式", experiment.mode || "-"), + opsFieldLine("是否开启", opsBooleanLabel(experiment.enabled)), + opsFieldLine("证据充分度", experiment.evidence_sufficiency || "-"), + ]), + opsSection("覆盖与决策", [ + opsFieldLine("决策数", experiment.decision_count ?? 0), + opsFieldLine("覆盖世界数", experiment.world_coverage_count ?? 0), + opsFieldLine("进入分桶", experiment.in_bucket_count ?? 0), + opsFieldLine("原本会拦截", experiment.would_block_count ?? 0), + opsFieldLine("辅助拦截", experiment.assisted_block_count ?? 0), + ]), + opsSection("影响指标", [ + opsFieldLine("继续阅读相关性", experiment.continuation_correlation !== null && experiment.continuation_correlation !== undefined ? Number(experiment.continuation_correlation).toFixed(2) : "-"), + opsFieldLine("付费相关性", experiment.monetization_correlation !== null && experiment.monetization_correlation !== undefined ? Number(experiment.monetization_correlation).toFixed(2) : "-"), + opsFieldLine("拦截→支付", experiment.assisted_block_to_checkout_correlation !== null && experiment.assisted_block_to_checkout_correlation !== undefined ? Number(experiment.assisted_block_to_checkout_correlation).toFixed(2) : "-"), + opsFieldLine("拦截→订阅", experiment.assisted_block_to_subscription_correlation !== null && experiment.assisted_block_to_subscription_correlation !== undefined ? Number(experiment.assisted_block_to_subscription_correlation).toFixed(2) : "-"), + opsFieldLine("拦截→挡板", experiment.assisted_block_to_paywall_correlation !== null && experiment.assisted_block_to_paywall_correlation !== undefined ? Number(experiment.assisted_block_to_paywall_correlation).toFixed(2) : "-"), + opsFieldLine("建议动作", experiment.recommended_next_action || "-"), + ]) + )}

`; - els.opsLearnedImpact.appendChild(experimentCard); + dom.opsLearnedImpact.appendChild(experimentCard); const worldCard = document.createElement("article"); worldCard.className = "list-card"; @@ -1671,7 +2857,7 @@ function renderOpsLearnedSection() {

${(learnedImpact.world_impact_details || []).slice(0, 5).map((item) => `${item.world_id}\ncontinuation ${item.continuation_correlation !== null && item.continuation_correlation !== undefined ? Number(item.continuation_correlation).toFixed(2) : "-"} · samples ${item.continuation_sample_count ?? 0} · gap ${item.continuation_sample_gap ?? 0}\ncheckout ${item.checkout_started_count ?? 0} · activated ${item.subscription_activated_count ?? 0} · paywall ${item.payment_required_count ?? 0}\nassisted decisions ${item.assisted_gate_decision_count ?? 0} · in bucket ${item.assisted_gate_in_bucket_count ?? 0} · assisted block ${item.assisted_gate_assisted_block_count ?? 0}\nevaluator ${item.evaluator_agreement_rate !== null && item.evaluator_agreement_rate !== undefined ? Number(item.evaluator_agreement_rate).toFixed(2) : "-"} · reranker ${item.reranker_accuracy !== null && item.reranker_accuracy !== undefined ? Number(item.reranker_accuracy).toFixed(2) : "-"}\nnext ${item.recommended_next_action || "-"}`).join("\n\n") || "-"}

`; - els.opsLearnedImpact.appendChild(worldCard); + dom.opsLearnedImpact.appendChild(worldCard); const issueCard = document.createElement("article"); issueCard.className = "list-card"; @@ -1682,7 +2868,7 @@ function renderOpsLearnedSection() {

${(learnedImpact.issue_impact_details || []).slice(0, 5).map((item) => `${item.issue_code}\naffected worlds ${item.affected_world_count ?? 0} · evaluator samples ${item.evaluator_sample_count ?? 0} · reranker samples ${item.reranker_sample_count ?? 0}\ncontinuation ${item.continuation_correlation !== null && item.continuation_correlation !== undefined ? Number(item.continuation_correlation).toFixed(2) : "-"} · monetization ${item.monetization_correlation !== null && item.monetization_correlation !== undefined ? Number(item.monetization_correlation).toFixed(2) : "-"}\npaywall ${item.payment_required_count ?? 0} · checkout ${item.checkout_started_count ?? 0} · activated ${item.subscription_activated_count ?? 0}\nassisted decisions ${item.assisted_gate_decision_count ?? 0} · assisted block ${item.assisted_gate_assisted_block_count ?? 0}\nnext ${item.recommended_next_action || "-"}`).join("\n\n") || "-"}

`; - els.opsLearnedImpact.appendChild(issueCard); + dom.opsLearnedImpact.appendChild(issueCard); const accumulationCard = document.createElement("article"); accumulationCard.className = "list-card"; @@ -1693,11 +2879,11 @@ function renderOpsLearnedSection() {

retention target/world ${learnedImpact.sample_accumulation?.retention?.target_sample_count_per_world ?? 0} · worlds below ${learnedImpact.sample_accumulation?.retention?.worlds_below_target_count ?? 0}\nevaluator target/world ${learnedImpact.sample_accumulation?.evaluator?.target_sample_count_per_world ?? 0} · worlds below ${learnedImpact.sample_accumulation?.evaluator?.worlds_below_target_count ?? 0}\nreranker target/world ${learnedImpact.sample_accumulation?.reranker?.target_sample_count_per_world ?? 0} · worlds below ${learnedImpact.sample_accumulation?.reranker?.worlds_below_target_count ?? 0}\n\nwarnings:\n${(learnedImpact.warnings || []).join("\n") || "-"}

`; - els.opsLearnedImpact.appendChild(accumulationCard); + dom.opsLearnedImpact.appendChild(accumulationCard); } if (!learnedCadence.track_summaries?.length) { - clearNode(els.opsLearnedCadence, "这里会显示 evaluator / reranker 当前处于 collect data、train、validate、promotion 还是 activate 阶段。"); + clearNode(dom.opsLearnedCadence, "这里会显示评估器和重排器当前处于补数据、训练、验证、晋升还是全量启用阶段。"); } else { const cadenceSummaryCard = document.createElement("article"); cadenceSummaryCard.className = "list-card"; @@ -1708,7 +2894,7 @@ function renderOpsLearnedSection() {

active ${(learnedCadence.cadence_summary?.active_tracks || []).join(" / ") || "-"}\nready ${(learnedCadence.cadence_summary?.ready_queue || []).join(" / ") || "-"} · attention ${(learnedCadence.cadence_summary?.attention_queue || []).join(" / ") || "-"}\nactivate ${(learnedCadence.cadence_summary?.activation_queue || []).join(" / ") || "-"}\npromotion ${(learnedCadence.cadence_summary?.promotion_queue || []).join(" / ") || "-"}\nvalidate ${(learnedCadence.cadence_summary?.validation_queue || []).join(" / ") || "-"}\ntraining ${(learnedCadence.cadence_summary?.training_queue || []).join(" / ") || "-"}\ncollect ${(learnedCadence.cadence_summary?.collection_queue || []).join(" / ") || "-"}\nrebuild ${(learnedCadence.cadence_summary?.rebuild_queue || []).join(" / ") || "-"}\n\nwarnings:\n${(learnedCadence.warnings || []).join("\n") || "-"}

`; - els.opsLearnedCadence.appendChild(cadenceSummaryCard); + dom.opsLearnedCadence.appendChild(cadenceSummaryCard); (learnedCadence.track_summaries || []).forEach((item) => { const card = document.createElement("article"); @@ -1720,22 +2906,22 @@ function renderOpsLearnedSection() {

next ${item.recommended_next_action || "-"}\nexamples ${item.relevant_example_count ?? 0} · worlds ${item.world_coverage_count ?? 0} · issues ${item.issue_coverage_count ?? 0}\nlatest sample ${item.latest_sample_at ? formatTimestamp(item.latest_sample_at) : "-"}\nartifact ${item.artifact_state?.artifact_present ? "present" : "missing"} · freshness ${item.freshness?.status || "-"}\ncheckpoint ${item.checkpoint_summary?.split_status || "-"} · train ${item.checkpoint_summary?.train_count ?? 0} / val ${item.checkpoint_summary?.val_count ?? 0} / test ${item.checkpoint_summary?.test_count ?? 0}\nshadow ${item.validation_summary?.shadow_status || "-"} · impact ${item.validation_summary?.impact_status || "-"} · sufficiency ${item.validation_summary?.evidence_sufficiency || "-"}\nshadow metric ${item.validation_summary?.shadow_agreement_or_accuracy !== null && item.validation_summary?.shadow_agreement_or_accuracy !== undefined ? Number(item.validation_summary.shadow_agreement_or_accuracy).toFixed(3) : "-"}\npromotion ${item.promotion_summary?.recommendation_status || "-"} · approval ${item.promotion_summary?.approval_status || "-"} · age ${item.promotion_summary?.hours_since_approval !== null && item.promotion_summary?.hours_since_approval !== undefined ? Number(item.promotion_summary.hours_since_approval).toFixed(1) : "-"}h\nrollout ${item.rollout_summary?.rollout_status || "-"} · safe ${item.rollout_summary?.safe_to_rollout ? "yes" : "no"} · age ${item.rollout_summary?.hours_since_rollout !== null && item.rollout_summary?.hours_since_rollout !== undefined ? Number(item.rollout_summary.hours_since_rollout).toFixed(1) : "-"}h\ntraining run ${(item.latest_training_run?.run_id || "-")} · ${(item.latest_training_run?.status || "never")}\nsource counts ${Object.entries(item.source_sample_counts || {}).map(([key, value]) => `${key}=${value}`).join(" / ") || "-"}\ncoverage gaps review ${item.coverage_gaps?.review_sample_backlog_count ?? 0} · pair ${item.coverage_gaps?.pair_coverage_backlog_count ?? 0} · disagreement ${item.coverage_gaps?.disagreement_issue_count ?? 0}\nstale ${(item.stale_reasons || []).join(" / ") || "-"}\nrecent events:\n${(item.recent_events || []).map((event) => `${event.event_type} · ${event.status || "-"} · ${event.occurred_at ? formatTimestamp(event.occurred_at) : "-"}\n${event.summary || "-"}`).join("\n\n") || "-"}\n\nwarnings:\n${(item.warnings || []).join("\n") || "-"}

`; - els.opsLearnedCadence.appendChild(card); + dom.opsLearnedCadence.appendChild(card); }); } if (!assistedGate.config) { - clearNode(els.opsLearnedAssistedGate, "这里会显示 assisted gate experiment 的 config、guardrails、recent decisions 与 rollback 条件。"); + clearNode(dom.opsLearnedAssistedGate, "这里会显示辅助门控实验的配置、护栏、最近决策与回滚条件。"); } else { const config = assistedGate.config || {}; - if (els.opsAssistedGateBucket) { - els.opsAssistedGateBucket.value = String(config.config?.bucket_percentage ?? 0); + if (dom.opsAssistedGateBucket) { + dom.opsAssistedGateBucket.value = String(config.config?.bucket_percentage ?? 0); } - if (els.opsAssistedGateConfidence) { - els.opsAssistedGateConfidence.value = String(config.config?.confidence_threshold ?? 0.9); + if (dom.opsAssistedGateConfidence) { + dom.opsAssistedGateConfidence.value = String(config.config?.confidence_threshold ?? 0.9); } - if (els.opsAssistedGateWorldAllowlist) { - els.opsAssistedGateWorldAllowlist.value = (config.config?.world_allowlist || []).join(", "); + if (dom.opsAssistedGateWorldAllowlist) { + dom.opsAssistedGateWorldAllowlist.value = (config.config?.world_allowlist || []).join(", "); } const configCard = document.createElement("article"); configCard.className = "list-card"; @@ -1746,7 +2932,7 @@ function renderOpsLearnedSection() {

track ${assistedGate.track || "evaluator"}\nrecommended ${assistedGate.recommended_next_action || "-"}\nreviewer ${config.reviewer_id || "-"} · updated ${config.updated_at ? formatTimestamp(config.updated_at) : "-"}\nreason ${config.reason || "-"}\nbucket ${config.config?.bucket_percentage ?? 0}% · threshold ${config.config?.confidence_threshold ?? 0}\nallowlist ${(config.config?.world_allowlist || []).join(" / ") || "-"}\nrollout ${assistedGate.rollout_summary?.rollout_status || "-"} · candidate ${assistedGate.rollout_summary?.candidate_ready ? "yes" : "no"} · approval ${assistedGate.rollout_summary?.latest_approval_status || "-"}\n\nguardrails:\n${(assistedGate.guardrails || []).join("\n") || "-"}\n\nrollback:\n${(assistedGate.rollback_conditions || []).join("\n") || "-"}

`; - els.opsLearnedAssistedGate.appendChild(configCard); + dom.opsLearnedAssistedGate.appendChild(configCard); const counterCard = document.createElement("article"); counterCard.className = "list-card"; @@ -1757,7 +2943,7 @@ function renderOpsLearnedSection() {

decisions ${assistedGate.counters?.decision_count ?? 0}\nshadow ${assistedGate.counters?.shadow_count ?? 0} · skipped ${assistedGate.counters?.skipped_count ?? 0}\nwould block ${assistedGate.counters?.would_block_count ?? 0} · in bucket ${assistedGate.counters?.in_bucket_count ?? 0}\nassisted block ${assistedGate.counters?.assisted_block_count ?? 0}

`; - els.opsLearnedAssistedGate.appendChild(counterCard); + dom.opsLearnedAssistedGate.appendChild(counterCard); if ((assistedGate.recent_decisions || []).length) { const decisionsCard = document.createElement("article"); @@ -1769,28 +2955,28 @@ function renderOpsLearnedSection() {

${(assistedGate.recent_decisions || []).slice(0, 6).map((item) => `${item.world_version_id || "-"}\n${item.status || "-"} · ${item.mode || "-"} · ${item.guardrail_status || "-"}\nbucket ${item.bucket_match ? "yes" : "no"} · would_block ${item.would_block ? "yes" : "no"} · action ${item.assisted_action || "-"}\nfinal ${(item.final_gate_errors || []).join(" / ") || "-"}\nupdated ${item.updated_at ? formatTimestamp(item.updated_at) : "-"}`).join("\n\n")}

`; - els.opsLearnedAssistedGate.appendChild(decisionsCard); + dom.opsLearnedAssistedGate.appendChild(decisionsCard); } } if (!assistedRerank.config) { - clearNode(els.opsLearnedAssistedRerank, "这里会显示 assisted rerank experiment 的 config、guardrails、recent decisions 与 rollback 条件。"); + clearNode(dom.opsLearnedAssistedRerank, "这里会显示辅助重排实验的配置、护栏、最近决策与回滚条件。"); } else { const config = assistedRerank.config || {}; - if (els.opsAssistedRerankBucket) { - els.opsAssistedRerankBucket.value = String(config.config?.bucket_percentage ?? 0); + if (dom.opsAssistedRerankBucket) { + dom.opsAssistedRerankBucket.value = String(config.config?.bucket_percentage ?? 0); } - if (els.opsAssistedRerankConfidence) { - els.opsAssistedRerankConfidence.value = String(config.config?.confidence_threshold ?? 0.65); + if (dom.opsAssistedRerankConfidence) { + dom.opsAssistedRerankConfidence.value = String(config.config?.confidence_threshold ?? 0.65); } - if (els.opsAssistedRerankCandidateWindow) { - els.opsAssistedRerankCandidateWindow.value = String(config.config?.candidate_window ?? 3); + if (dom.opsAssistedRerankCandidateWindow) { + dom.opsAssistedRerankCandidateWindow.value = String(config.config?.candidate_window ?? 3); } - if (els.opsAssistedRerankMaxScoreGap) { - els.opsAssistedRerankMaxScoreGap.value = String(config.config?.max_score_gap ?? 0.08); + if (dom.opsAssistedRerankMaxScoreGap) { + dom.opsAssistedRerankMaxScoreGap.value = String(config.config?.max_score_gap ?? 0.08); } - if (els.opsAssistedRerankWorldAllowlist) { - els.opsAssistedRerankWorldAllowlist.value = (config.config?.world_allowlist || []).join(", "); + if (dom.opsAssistedRerankWorldAllowlist) { + dom.opsAssistedRerankWorldAllowlist.value = (config.config?.world_allowlist || []).join(", "); } const configCard = document.createElement("article"); configCard.className = "list-card"; @@ -1801,7 +2987,7 @@ function renderOpsLearnedSection() {

track ${assistedRerank.track || "reranker"}\nrecommended ${assistedRerank.recommended_next_action || "-"}\nreviewer ${config.reviewer_id || "-"} · updated ${config.updated_at ? formatTimestamp(config.updated_at) : "-"}\nreason ${config.reason || "-"}\nbucket ${config.config?.bucket_percentage ?? 0}% · threshold ${config.config?.confidence_threshold ?? 0}\nwindow ${config.config?.candidate_window ?? 0} · max gap ${config.config?.max_score_gap ?? 0}\nallowlist ${(config.config?.world_allowlist || []).join(" / ") || "-"}\nrollout ${assistedRerank.rollout_summary?.rollout_status || "-"} · candidate ${assistedRerank.rollout_summary?.candidate_ready ? "yes" : "no"} · approval ${assistedRerank.rollout_summary?.latest_approval_status || "-"}\n\nguardrails:\n${(assistedRerank.guardrails || []).join("\n") || "-"}\n\nrollback:\n${(assistedRerank.rollback_conditions || []).join("\n") || "-"}

`; - els.opsLearnedAssistedRerank.appendChild(configCard); + dom.opsLearnedAssistedRerank.appendChild(configCard); const counterCard = document.createElement("article"); counterCard.className = "list-card"; @@ -1812,7 +2998,7 @@ function renderOpsLearnedSection() {

decisions ${assistedRerank.counters?.decision_count ?? 0}\nshadow ${assistedRerank.counters?.shadow_count ?? 0} · skipped ${assistedRerank.counters?.skipped_count ?? 0}\nwould swap ${assistedRerank.counters?.would_swap_count ?? 0} · in bucket ${assistedRerank.counters?.in_bucket_count ?? 0}\nassisted swap ${assistedRerank.counters?.assisted_swap_count ?? 0}

`; - els.opsLearnedAssistedRerank.appendChild(counterCard); + dom.opsLearnedAssistedRerank.appendChild(counterCard); if ((assistedRerank.recent_decisions || []).length) { const decisionsCard = document.createElement("article"); @@ -1824,14 +3010,14 @@ function renderOpsLearnedSection() {

${(assistedRerank.recent_decisions || []).slice(0, 6).map((item) => `${item.world_version_id || "-"}\n${item.status || "-"} · ${item.mode || "-"} · beat ${item.beat_index || "-"}\nbucket ${item.bucket_match ? "yes" : "no"} · would_swap ${item.would_swap ? "yes" : "no"} · action ${item.assisted_action || "-"}\nbaseline ${item.baseline_event_id || "-"} -> selected ${item.selected_event_id || "-"}\nupdated ${item.updated_at ? formatTimestamp(item.updated_at) : "-"}`).join("\n\n")}

`; - els.opsLearnedAssistedRerank.appendChild(decisionsCard); + dom.opsLearnedAssistedRerank.appendChild(decisionsCard); } } - if (!appState.opsLearnedReviewQuality) { - clearNode(els.opsLearnedReviewQuality, "这里会显示 human review coverage、reviewer diversity、样本质量告警与高覆盖补样 backlog。"); + if (!opsState.opsLearnedReviewQuality) { + clearNode(dom.opsLearnedReviewQuality, "这里会显示人工审阅覆盖、审阅人分布、样本质量告警与高覆盖补样待办。"); } else { - const reviewQuality = appState.opsLearnedReviewQuality; + const reviewQuality = opsState.opsLearnedReviewQuality; const qualityCard = document.createElement("article"); qualityCard.className = "list-card"; qualityCard.innerHTML = ` @@ -1841,7 +3027,7 @@ function renderOpsLearnedSection() {

samples ${reviewQuality.quality_summary?.sample_count ?? 0} · worlds ${reviewQuality.quality_summary?.world_coverage_count ?? 0} · versions ${reviewQuality.quality_summary?.version_coverage_count ?? 0}\nvalidated refs ${reviewQuality.quality_summary?.validated_reference_rate !== null && reviewQuality.quality_summary?.validated_reference_rate !== undefined ? Number(reviewQuality.quality_summary.validated_reference_rate).toFixed(2) : "-"}\nwarning samples ${reviewQuality.quality_summary?.warning_sample_count ?? 0}\nmissing session ${reviewQuality.quality_summary?.missing_session_context_count ?? 0} · missing issues ${reviewQuality.quality_summary?.missing_linked_issue_codes_count ?? 0} · ref not validated ${reviewQuality.quality_summary?.reference_not_validated_count ?? 0}\ntarget/world ${reviewQuality.coverage_summary?.target_sample_count_per_world ?? 0} · reviewer diversity ${reviewQuality.coverage_summary?.target_reviewer_diversity_per_world ?? 0}\nworld gaps ${reviewQuality.coverage_summary?.worlds_below_target_count ?? 0} · low diversity ${reviewQuality.coverage_summary?.low_diversity_world_count ?? 0} · focus issue gaps ${reviewQuality.coverage_summary?.focus_issue_gap_world_count ?? 0}\nshared weak worlds ${(reviewQuality.coverage_summary?.shared_weak_worlds || []).join(" / ") || "-"}\nwarnings:\n${(reviewQuality.warnings || []).join("\n") || "-"}

`; - els.opsLearnedReviewQuality.appendChild(qualityCard); + dom.opsLearnedReviewQuality.appendChild(qualityCard); const backlogCard = document.createElement("article"); backlogCard.className = "list-card"; @@ -1852,7 +3038,7 @@ function renderOpsLearnedSection() {

${(reviewQuality.replenishment_backlog || []).slice(0, 5).map((item) => `${item.world_id}\npriority ${item.priority} · action ${item.recommended_action}\ncoverage ${item.human_review_count ?? 0}/${reviewQuality.coverage_summary?.target_sample_count_per_world ?? 0} · gap ${item.coverage_gap ?? 0}\nreviewers ${item.reviewer_diversity_count ?? 0}/${reviewQuality.coverage_summary?.target_reviewer_diversity_per_world ?? 0} · gap ${item.reviewer_diversity_gap ?? 0}\nfocus issue gaps ${(item.focus_issue_gaps || []).join(" / ") || "-"}\nwarning samples ${item.warning_sample_count ?? 0}\ncandidate chapters ${(item.candidate_backlog_chapters || []).join(" / ") || "-"}`).join("\n\n") || "-"}

`; - els.opsLearnedReviewQuality.appendChild(backlogCard); + dom.opsLearnedReviewQuality.appendChild(backlogCard); const flaggedCard = document.createElement("article"); flaggedCard.className = "list-card"; @@ -1863,7 +3049,7 @@ function renderOpsLearnedSection() {

${(reviewQuality.flagged_samples || []).slice(0, 5).map((item) => `${item.sample_id}\n${item.world_id} · ${item.chapter_id} · reviewer ${item.reviewer_id || "-"}\nref ${item.reference_status || "-"} · warnings ${(item.ingestion_warnings || []).join(" / ") || "-"}\nlinked issues ${(item.linked_issue_codes || []).join(" / ") || "-"}\nnotes ${item.freeform_notes || "-"}`).join("\n\n") || "-"}

`; - els.opsLearnedReviewQuality.appendChild(flaggedCard); + dom.opsLearnedReviewQuality.appendChild(flaggedCard); } const artifactCard = document.createElement("article"); @@ -1875,7 +3061,7 @@ function renderOpsLearnedSection() {

evaluator ${dashboard.artifact_status?.evaluator?.available ? "available" : "missing"} · ${dashboard.artifact_status?.evaluator?.artifact_dir || "-"}\npublished ${dashboard.evaluator_shadow_summary?.published_at ? formatTimestamp(dashboard.evaluator_shadow_summary.published_at) : "-"}\nsource ${dashboard.evaluator_shadow_summary?.source_output_dir || "-"}\nfiles ${(dashboard.evaluator_shadow_summary?.artifact_files || []).join(" / ") || "-"}\n\nreranker ${dashboard.artifact_status?.reranker?.available ? "available" : "missing"} · ${dashboard.artifact_status?.reranker?.artifact_dir || "-"}\npublished ${dashboard.reranker_shadow_summary?.published_at ? formatTimestamp(dashboard.reranker_shadow_summary.published_at) : "-"}\nsource ${dashboard.reranker_shadow_summary?.source_output_dir || "-"}\nfiles ${(dashboard.reranker_shadow_summary?.artifact_files || []).join(" / ") || "-"}

`; - els.opsLearnedDashboard.appendChild(artifactCard); + dom.opsLearnedDashboard.appendChild(artifactCard); const coverageCard = document.createElement("article"); coverageCard.className = "list-card"; @@ -1886,14 +3072,14 @@ function renderOpsLearnedSection() {

evaluator low coverage:\n${(dashboard.coverage_summary?.evaluator_low_coverage_worlds || []).map((item) => `${item.world_id}=${item.count}`).join("\n") || "-"}\n\nreranker low coverage:\n${(dashboard.coverage_summary?.reranker_low_pair_coverage_worlds || []).map((item) => `${item.world_id}=${item.count}`).join("\n") || "-"}

`; - els.opsLearnedDashboard.appendChild(coverageCard); + dom.opsLearnedDashboard.appendChild(coverageCard); - if (!appState.opsLearnedTrainingResult) { - clearNode(els.opsLearnedTraining, "这里会显示最近一次 learned training automation 结果。"); + if (!opsState.opsLearnedTrainingResult) { + clearNode(dom.opsLearnedTraining, "这里会显示最近一次学习层训练自动化结果。"); } else { - const run = appState.opsLearnedTrainingResult; + const run = opsState.opsLearnedTrainingResult; if (run.job) { - els.opsLearnedTraining.appendChild( + dom.opsLearnedTraining.appendChild( createListCard({ title: "Latest Learned Training Job", score: run.job.status || "-", @@ -1907,7 +3093,7 @@ function renderOpsLearnedSection() { }) ); } else { - els.opsLearnedTraining.appendChild( + dom.opsLearnedTraining.appendChild( createListCard({ title: "Latest Learned Training Run", score: `${(run.summary?.tracks_succeeded || []).length}/${(run.summary?.tracks_requested || []).length}`, @@ -1922,16 +3108,16 @@ function renderOpsLearnedSection() { } } - if (!appState.opsLearnedEvidence) { - clearNode(els.opsLearnedEvidence, "这里会显示 evaluator / reranker 的 promotion evidence pack 摘要。"); + if (!opsState.opsLearnedEvidence) { + clearNode(dom.opsLearnedEvidence, "这里会显示评估器和重排器的发布证据包摘要。"); } else { ["evaluator", "reranker"].forEach((track) => { - const evidence = appState.opsLearnedEvidence?.[track]; + const evidence = opsState.opsLearnedEvidence?.[track]; if (!evidence || !evidence.evidence_pack) return; const pack = evidence.evidence_pack; const summary = pack.evidence_summary || {}; const artifactState = pack.artifact_state || {}; - els.opsLearnedEvidence.appendChild( + dom.opsLearnedEvidence.appendChild( createListCard({ title: `${track} promotion evidence`, score: summary.status || "-", @@ -1948,28 +3134,50 @@ function renderOpsLearnedSection() { } } - clearNode(els.opsLearnedCompare); - if (!appState.opsLearnedCompare) { - clearNode(els.opsLearnedCompare, "这里会显示 evaluator / reranker 的 shadow candidate compare。"); + clearNode(dom.opsLearnedCompare); + if (!opsState.opsLearnedCompare) { + clearNode(dom.opsLearnedCompare, "这里会显示评估器 / 重排器的影子候选对比。"); } else { - const compare = appState.opsLearnedCompare; - const compareCard = document.createElement("article"); - compareCard.className = "list-card"; - compareCard.innerHTML = ` -
-

Shadow Candidate Compare

- ${compare.preferred_shadow_candidate || "neither"} -
-

next ${compare.recommended_next_action || "-"}\nsafe rollout ${(compare.safe_rollout_candidates || []).join(" / ") || "-"}\n\nevaluator:\nstatus ${compare.evaluator_status || "-"}\nagreement ${compare.evaluator_scorecard?.agreement_rate !== null && compare.evaluator_scorecard?.agreement_rate !== undefined ? Number(compare.evaluator_scorecard.agreement_rate).toFixed(3) : "-"}\nsplits ${compare.evaluator_scorecard?.train_count || 0}/${compare.evaluator_scorecard?.val_count || 0}/${compare.evaluator_scorecard?.test_count || 0}\nwarnings ${(compare.evaluator_scorecard?.warnings || []).join(" / ") || "-"}\nrollout ${compare.rollout_readiness?.evaluator?.candidate_ready ? "ready" : "hold"} · hint ${compare.rollout_readiness?.evaluator?.approval_hint || "-"}\n\nreranker:\nstatus ${compare.reranker_status || "-"}\navg accuracy ${compare.reranker_scorecard?.average_world_accuracy !== null && compare.reranker_scorecard?.average_world_accuracy !== undefined ? Number(compare.reranker_scorecard.average_world_accuracy).toFixed(3) : "-"}\nsplits ${compare.reranker_scorecard?.train_count || 0}/${compare.reranker_scorecard?.val_count || 0}/${compare.reranker_scorecard?.test_count || 0}\nwarnings ${(compare.reranker_scorecard?.warnings || []).join(" / ") || "-"}\nrollout ${compare.rollout_readiness?.reranker?.candidate_ready ? "ready" : "hold"} · hint ${compare.rollout_readiness?.reranker?.approval_hint || "-"}\n\ndisagreement worlds ${(compare.disagreement_worlds || []).map((item) => `${item.world_id}:${item.evaluator_signal}/${item.reranker_signal}`).join(" / ") || "-"}\ndisagreement issues ${(compare.disagreement_issue_codes || []).map((item) => item.issue_code).join(" / ") || "-"}

- `; - els.opsLearnedCompare.appendChild(compareCard); + const compare = opsState.opsLearnedCompare; + const compareCard = createListCard({ + title: "影子候选对比", + score: opsPreferredCandidateLabel(compare.preferred_shadow_candidate), + body: opsSections( + opsSection("总体判断", [ + opsFieldLine("偏好候选", opsPreferredCandidateLabel(compare.preferred_shadow_candidate)), + opsFieldLine("下一步", compare.recommended_next_action || "-"), + opsFieldLine("可安全发布", opsTrackList(compare.safe_rollout_candidates || [])), + ]), + opsSection("评估器", [ + opsFieldLine("状态", opsStatusLabel(compare.evaluator_status || "-")), + opsFieldLine("一致率", opsNumericValue(compare.evaluator_scorecard?.agreement_rate, 3)), + opsFieldLine("训练/验证/测试", `${compare.evaluator_scorecard?.train_count || 0} / ${compare.evaluator_scorecard?.val_count || 0} / ${compare.evaluator_scorecard?.test_count || 0}`), + opsFieldLine("告警", (compare.evaluator_scorecard?.warnings || []).join(" / ") || "-"), + opsFieldLine("发布准备", compare.rollout_readiness?.evaluator?.candidate_ready ? "可以发布" : "继续观察"), + opsFieldLine("审批提示", compare.rollout_readiness?.evaluator?.approval_hint || "-"), + ]), + opsSection("重排器", [ + opsFieldLine("状态", opsStatusLabel(compare.reranker_status || "-")), + opsFieldLine("平均准确率", opsNumericValue(compare.reranker_scorecard?.average_world_accuracy, 3)), + opsFieldLine("训练/验证/测试", `${compare.reranker_scorecard?.train_count || 0} / ${compare.reranker_scorecard?.val_count || 0} / ${compare.reranker_scorecard?.test_count || 0}`), + opsFieldLine("告警", (compare.reranker_scorecard?.warnings || []).join(" / ") || "-"), + opsFieldLine("发布准备", compare.rollout_readiness?.reranker?.candidate_ready ? "可以发布" : "继续观察"), + opsFieldLine("审批提示", compare.rollout_readiness?.reranker?.approval_hint || "-"), + ]), + opsSection("分歧情况", [ + opsFieldLine("世界分歧", (compare.disagreement_worlds || []).map((item) => `${opsWorldLabel(item.world_id)}:${item.evaluator_signal}/${item.reranker_signal}`).join(" / ") || "-"), + opsFieldLine("问题分歧", opsIssueCodeList((compare.disagreement_issue_codes || []).map((item) => item.issue_code || item))), + ]) + ) + }); + dom.opsLearnedCompare.appendChild(compareCard); } - clearNode(els.opsLearnedRollout); - if (!appState.opsLearnedRollout) { - clearNode(els.opsLearnedRollout, "这里会显示 learned rollout summary、safe candidates 与 rollback watchlist。"); + clearNode(dom.opsLearnedRollout); + if (!opsState.opsLearnedRollout) { + clearNode(dom.opsLearnedRollout, "这里会显示学习层灰度摘要、可安全发布候选与回滚观察名单。"); } else { - const rollout = appState.opsLearnedRollout; + const rollout = opsState.opsLearnedRollout; const summaryCard = document.createElement("article"); summaryCard.className = "list-card"; summaryCard.innerHTML = ` @@ -1979,7 +3187,7 @@ function renderOpsLearnedSection() {

preferred ${rollout.preferred_shadow_candidate || "neither"}\nnext ${rollout.recommended_next_action || "-"}\nactive ${(rollout.active_tracks || []).join(" / ") || "-"}\nsafe ${(rollout.safe_rollout_candidates || []).join(" / ") || "-"}\nrollback ${(rollout.rollback_watchlist || []).join(" / ") || "-"}

`; - els.opsLearnedRollout.appendChild(summaryCard); + dom.opsLearnedRollout.appendChild(summaryCard); ["evaluator", "reranker"].forEach((track) => { const item = rollout.tracks?.[track]; @@ -1999,49 +3207,49 @@ function renderOpsLearnedSection() { `; card.querySelector(".learned-rollout-activate")?.addEventListener("click", () => submitLearnedRollout(track, "activate")); card.querySelector(".learned-rollout-rollback")?.addEventListener("click", () => submitLearnedRollout(track, "rollback")); - els.opsLearnedRollout.appendChild(card); + dom.opsLearnedRollout.appendChild(card); }); } - clearNode(els.opsLearnedPromotion); - if (!appState.opsLearnedPromotion) { - clearNode(els.opsLearnedPromotion, "这里会显示 evaluator 的 promotion recommendation、blockers、advisories 与 checklist。"); + clearNode(dom.opsLearnedPromotion); + if (!opsState.opsLearnedPromotion) { + clearNode(dom.opsLearnedPromotion, "这里会显示评估器发布门、阻塞项、提示项与检查清单。"); } else { - const promotion = appState.opsLearnedPromotion; - const card = document.createElement("article"); - card.className = "list-card"; - card.innerHTML = ` -
-

Evaluator Promotion Gate

- ${promotion.approval_status || promotion.recommendation_status || "-"} -
-

track ${promotion.track || "evaluator"} · scope ${promotion.scope || "global"} · mode ${promotion.mode || "manual_approval"}\nrecommendation ${promotion.recommendation_status || promotion.status || "-"}\napproval ${promotion.approval_status || "unapproved"}\nreconfirm required ${promotion.reconfirm_required ? "yes" : "no"}\nnext ${promotion.recommended_action || "-"}\n\nlatest approval:\n${promotion.latest_approval_record ? `${promotion.latest_approval_record.status} · ${promotion.latest_approval_record.reviewer_id || "-"} · ${promotion.latest_approval_record.updated_at || "-"}\nreason ${promotion.latest_approval_record.reason || "-"}` : "暂无"}\n\nblockers ${(promotion.blockers || []).join(" / ") || "-"}\nadvisories ${(promotion.advisories || []).join(" / ") || "-"}\n\nevidence:\nagreement ${promotion.evidence?.agreement_rate !== null && promotion.evidence?.agreement_rate !== undefined ? Number(promotion.evidence.agreement_rate).toFixed(3) : "-"}\ntrain ${promotion.evidence?.train_count ?? 0} · val ${promotion.evidence?.val_count ?? 0} · test ${promotion.evidence?.test_count ?? 0}\npreferred ${promotion.evidence?.preferred_shadow_candidate || "neither"}\nreview backlog ${promotion.evidence?.review_backlog_count ?? 0}\npair backlog ${promotion.evidence?.pair_backlog_count ?? 0}\ndisagreement worlds ${promotion.evidence?.disagreement_world_count ?? 0}\ndisagreement issues ${promotion.evidence?.disagreement_issue_count ?? 0}\n\nchecklist:\n${(promotion.checklist || []).map((item) => `${item.ok ? "✓" : "×"} ${item.key} · ${item.reason}`).join("\n") || "-"}

- `; - els.opsLearnedPromotion.appendChild(card); + const promotion = opsState.opsLearnedPromotion; + const card = createListCard({ + title: "评估器发布门", + score: opsStatusLabel(promotion.approval_status || promotion.recommendation_status || "-"), + body: opsPromotionBody( + promotion, + "一致率", + opsNumericValue(promotion.evidence?.agreement_rate, 3) + ) + }); + dom.opsLearnedPromotion.appendChild(card); } - clearNode(els.opsLearnedRerankerPromotion); - if (!appState.opsLearnedRerankerPromotion) { - clearNode(els.opsLearnedRerankerPromotion, "这里会显示 reranker 的 promotion recommendation、blockers、advisories 与 checklist。"); + clearNode(dom.opsLearnedRerankerPromotion); + if (!opsState.opsLearnedRerankerPromotion) { + clearNode(dom.opsLearnedRerankerPromotion, "这里会显示重排器发布门、阻塞项、提示项与检查清单。"); } else { - const promotion = appState.opsLearnedRerankerPromotion; - const card = document.createElement("article"); - card.className = "list-card"; - card.innerHTML = ` -
-

Reranker Promotion Gate

- ${promotion.approval_status || promotion.recommendation_status || "-"} -
-

track ${promotion.track || "reranker"} · scope ${promotion.scope || "global"} · mode ${promotion.mode || "manual_approval"}\nrecommendation ${promotion.recommendation_status || promotion.status || "-"}\napproval ${promotion.approval_status || "unapproved"}\nreconfirm required ${promotion.reconfirm_required ? "yes" : "no"}\nnext ${promotion.recommended_action || "-"}\n\nlatest approval:\n${promotion.latest_approval_record ? `${promotion.latest_approval_record.status} · ${promotion.latest_approval_record.reviewer_id || "-"} · ${promotion.latest_approval_record.updated_at || "-"}\nreason ${promotion.latest_approval_record.reason || "-"}` : "暂无"}\n\nblockers ${(promotion.blockers || []).join(" / ") || "-"}\nadvisories ${(promotion.advisories || []).join(" / ") || "-"}\n\nevidence:\navg accuracy ${promotion.evidence?.average_world_accuracy !== null && promotion.evidence?.average_world_accuracy !== undefined ? Number(promotion.evidence.average_world_accuracy).toFixed(3) : "-"}\nlow error worlds ${promotion.evidence?.low_error_world_count ?? 0}\ntrain ${promotion.evidence?.train_count ?? 0} · val ${promotion.evidence?.val_count ?? 0} · test ${promotion.evidence?.test_count ?? 0}\npreferred ${promotion.evidence?.preferred_shadow_candidate || "neither"}\nreview backlog ${promotion.evidence?.review_backlog_count ?? 0}\npair backlog ${promotion.evidence?.pair_backlog_count ?? 0}\ndisagreement worlds ${promotion.evidence?.disagreement_world_count ?? 0}\ndisagreement issues ${promotion.evidence?.disagreement_issue_count ?? 0}\n\nchecklist:\n${(promotion.checklist || []).map((item) => `${item.ok ? "✓" : "×"} ${item.key} · ${item.reason}`).join("\n") || "-"}

- `; - els.opsLearnedRerankerPromotion.appendChild(card); + const promotion = opsState.opsLearnedRerankerPromotion; + const card = createListCard({ + title: "重排器发布门", + score: opsStatusLabel(promotion.approval_status || promotion.recommendation_status || "-"), + body: opsPromotionBody( + promotion, + "平均准确率", + opsNumericValue(promotion.evidence?.average_world_accuracy, 3) + ) + }); + dom.opsLearnedRerankerPromotion.appendChild(card); } - clearNode(els.opsLearnedWorlds); - if (!appState.opsLearnedDashboard?.world_details?.length) { - clearNode(els.opsLearnedWorlds, "这里会显示需要优先看的 worlds。"); + clearNode(dom.opsLearnedWorlds); + if (!opsState.opsLearnedDashboard?.world_details?.length) { + clearNode(dom.opsLearnedWorlds, "这里会显示需要优先关注的薄弱世界。"); } else { - appState.opsLearnedDashboard.world_details.forEach((item) => { + opsState.opsLearnedDashboard.world_details.forEach((item) => { const card = document.createElement("article"); card.className = "list-card"; card.innerHTML = ` @@ -2052,15 +3260,15 @@ function renderOpsLearnedSection() {

eval ${item.evaluator_agreement_rate !== null && item.evaluator_agreement_rate !== undefined ? Number(item.evaluator_agreement_rate).toFixed(3) : "-"}\nreranker ${item.reranker_accuracy !== null && item.reranker_accuracy !== undefined ? Number(item.reranker_accuracy).toFixed(3) : "-"}\nevaluator issues ${(item.evaluator_top_issues || []).join(" / ") || "-"}\nreranker issues ${(item.reranker_top_issues || []).join(" / ") || "-"}

`; card.addEventListener("click", () => openLearnedWorldDetail(item.world_id)); - els.opsLearnedWorlds.appendChild(card); + dom.opsLearnedWorlds.appendChild(card); }); } - clearNode(els.opsLearnedIssues); - if (!appState.opsLearnedDashboard?.issue_details?.length) { - clearNode(els.opsLearnedIssues, "这里会显示需要优先看的 issue codes。"); + clearNode(dom.opsLearnedIssues); + if (!opsState.opsLearnedDashboard?.issue_details?.length) { + clearNode(dom.opsLearnedIssues, "这里会显示需要优先关注的问题代码。"); } else { - appState.opsLearnedDashboard.issue_details.forEach((item) => { + opsState.opsLearnedDashboard.issue_details.forEach((item) => { const card = document.createElement("article"); card.className = "list-card"; card.innerHTML = ` @@ -2071,15 +3279,15 @@ function renderOpsLearnedSection() {

eval ${item.evaluator_error_rate !== null && item.evaluator_error_rate !== undefined ? Number(item.evaluator_error_rate).toFixed(3) : "-"}\nreranker ${item.reranker_error_rate !== null && item.reranker_error_rate !== undefined ? Number(item.reranker_error_rate).toFixed(3) : "-"}\nworlds ${(item.affected_worlds || []).join(" / ") || "-"}

`; card.addEventListener("click", () => openLearnedIssueDetail(item.issue_code)); - els.opsLearnedIssues.appendChild(card); + dom.opsLearnedIssues.appendChild(card); }); } - clearNode(els.opsLearnedDetail); - if (!appState.opsLearnedDetail) { - clearNode(els.opsLearnedDetail, "点击一个 world 或 issue 后,这里会显示 detail。"); - } else if (appState.opsLearnedDetail.world_id) { - const detail = appState.opsLearnedDetail; + clearNode(dom.opsLearnedDetail); + if (!opsState.opsLearnedDetail) { + clearNode(dom.opsLearnedDetail, "点击一个世界或问题后,这里会显示对应的深度详情。"); + } else if (opsState.opsLearnedDetail.world_id) { + const detail = opsState.opsLearnedDetail; const card = document.createElement("article"); card.className = "list-card"; card.innerHTML = ` @@ -2089,9 +3297,9 @@ function renderOpsLearnedSection() {

evaluator agreement ${detail.evaluator_agreement_rate !== null && detail.evaluator_agreement_rate !== undefined ? Number(detail.evaluator_agreement_rate).toFixed(3) : "-"}\nreranker accuracy ${detail.reranker_accuracy !== null && detail.reranker_accuracy !== undefined ? Number(detail.reranker_accuracy).toFixed(3) : "-"}\nevaluator coverage ${detail.evaluator_low_coverage ? "low" : "ok"}\nreranker coverage ${detail.reranker_low_coverage ? "low" : "ok"}\nevaluator issues ${(detail.evaluator_top_issues || []).join(" / ") || "-"}\nreranker issues ${(detail.reranker_top_issues || []).join(" / ") || "-"}

`; - els.opsLearnedDetail.appendChild(card); - } else if (appState.opsLearnedDetail.issue_code) { - const detail = appState.opsLearnedDetail; + dom.opsLearnedDetail.appendChild(card); + } else if (opsState.opsLearnedDetail.issue_code) { + const detail = opsState.opsLearnedDetail; const card = document.createElement("article"); card.className = "list-card"; card.innerHTML = ` @@ -2101,134 +3309,200 @@ function renderOpsLearnedSection() {

evaluator error ${detail.evaluator_error_rate !== null && detail.evaluator_error_rate !== undefined ? Number(detail.evaluator_error_rate).toFixed(3) : "-"}\nreranker error ${detail.reranker_error_rate !== null && detail.reranker_error_rate !== undefined ? Number(detail.reranker_error_rate).toFixed(3) : "-"}\naffected worlds ${(detail.affected_worlds || []).join(" / ") || "-"}

`; - els.opsLearnedDetail.appendChild(card); + dom.opsLearnedDetail.appendChild(card); } - clearNode(els.opsLearnedDataOps); - if (!appState.opsLearnedDataOps) { - clearNode(els.opsLearnedDataOps, "这里会显示 review backlog、pair coverage backlog 和 action queue。"); + clearNode(dom.opsLearnedDataOps); + if (!opsState.opsLearnedDataOps) { + clearNode(dom.opsLearnedDataOps, "这里会显示审核待办、配对覆盖待补样与行动队列。"); } else { - const summary = appState.opsLearnedDataOps; - const card = document.createElement("article"); - card.className = "list-card"; - card.innerHTML = ` -
-

Learned Data Ops

- ${summary.recommended_next_action || "-"} -
-

preferred ${summary.preferred_shadow_candidate || "neither"}\nreview backlog ${summary.coverage_gaps?.review_sample_backlog_count ?? 0}\npair backlog ${summary.coverage_gaps?.pair_coverage_backlog_count ?? 0}\nshared weak worlds ${(summary.coverage_gaps?.shared_weak_worlds || []).join(" / ") || "-"}\nshared weak issues ${(summary.coverage_gaps?.shared_weak_issue_codes || []).join(" / ") || "-"}\naction queue:\n${(summary.action_queue || []).map((item) => `${item.action_type} · ${item.world_id || "-"} · ${item.issue_code || item.chapter_id || "-"} · ${item.recommended_action || "-"}`).join("\n") || "-"}

- `; - els.opsLearnedDataOps.appendChild(card); + const summary = opsState.opsLearnedDataOps; + const card = createListCard({ + title: "学习层数据运营", + score: summary.recommended_next_action || "-", + body: opsSections( + opsSection("总体判断", [ + opsFieldLine("偏好候选", opsPreferredCandidateLabel(summary.preferred_shadow_candidate)), + opsFieldLine("推荐动作", summary.recommended_next_action || "-"), + ]), + opsSection("覆盖缺口", [ + opsFieldLine("审阅待补样", summary.coverage_gaps?.review_sample_backlog_count ?? 0), + opsFieldLine("配对覆盖待补样", summary.coverage_gaps?.pair_coverage_backlog_count ?? 0), + ]), + opsSection("共性薄弱项", [ + opsFieldLine("薄弱世界", opsWorldList(summary.coverage_gaps?.shared_weak_worlds || [])), + opsFieldLine("薄弱问题", opsIssueCodeList(summary.coverage_gaps?.shared_weak_issue_codes || [])), + ]), + opsSection("行动队列", [ + opsParagraphList(summary.action_queue || [], (item) => opsSections( + opsSection(item.action_type || "待办动作", [ + opsFieldLine("世界", item.world_id ? opsWorldLabel(item.world_id) : "-"), + opsFieldLine("问题", item.issue_code ? opsIssueCodeLabel(item.issue_code) : "-"), + opsFieldLine("章节", item.chapter_id || "-"), + opsFieldLine("建议动作", item.recommended_action || "-"), + ]) + )), + ]) + ) + }); + dom.opsLearnedDataOps.appendChild(card); } - clearNode(els.opsReviewSampleBacklog); - if (!appState.opsLearnedDataOps?.review_sample_backlog?.length) { - clearNode(els.opsReviewSampleBacklog, "这里会显示优先需要人工补样本的章节。"); + clearNode(dom.opsReviewSampleBacklog); + if (!opsState.opsLearnedDataOps?.review_sample_backlog?.length) { + clearNode(dom.opsReviewSampleBacklog, "这里会显示优先需要人工补样本的章节。"); } else { - appState.opsLearnedDataOps.review_sample_backlog.forEach((item) => { - const card = document.createElement("article"); - card.className = "list-card"; - if (appState.opsReviewCaptureTarget?.chapter_id === item.chapter_id) { + opsState.opsLearnedDataOps.review_sample_backlog.forEach((item) => { + const card = createListCard({ + title: `待补样章节 · ${item.chapter_id}`, + score: item.recommended_action || item.priority || "-", + body: opsSections( + opsSection("章节上下文", [ + opsFieldLine("世界", opsWorldLabel(item.world_id)), + opsFieldLine("世界版本", item.world_version_id || "-"), + opsFieldLine("当前结论", item.decision || "-"), + opsFieldLine("优先级", item.priority || "-"), + ]), + opsSection("问题信号", [ + opsFieldLine("问题列表", opsIssueCodeList(item.issue_codes || [])), + opsFieldLine("世界信号", item.world_compare_signal || "-"), + opsFieldLine("问题信号", opsIssueCodeList(item.issue_compare_signal || [])), + ]), + opsSection("摘要", [ + item.summary || "-", + ]) + ) + }); + if (opsState.opsReviewCaptureTarget?.chapter_id === item.chapter_id) { card.classList.add("is-selected"); } - card.innerHTML = ` -
-

${item.chapter_id}

- ${item.recommended_action || item.priority || "-"} -
-

world ${item.world_id}\ndecision ${item.decision}\npriority ${item.priority}\nissues ${(item.issue_codes || []).join(" / ") || "-"}\nworld signal ${item.world_compare_signal || "-"}\nissue signal ${(item.issue_compare_signal || []).join(" / ") || "-"}\nsummary ${item.summary || "-"}

- `; card.addEventListener("click", () => selectReviewBacklogItem(item)); - els.opsReviewSampleBacklog.appendChild(card); + dom.opsReviewSampleBacklog.appendChild(card); }); } - clearNode(els.opsPreferenceSamples); - if (!(appState.opsPreferenceSamples || []).length) { - clearNode(els.opsPreferenceSamples, "这里会显示最近采集的 preference samples。"); + clearNode(dom.opsPreferenceSamples); + if (!(opsState.opsPreferenceSamples || []).length) { + clearNode(dom.opsPreferenceSamples, "这里会显示最近采集的偏好样本。"); } else { - (appState.opsPreferenceSamples || []).slice(0, 5).forEach((item) => { - const card = document.createElement("article"); - card.className = "list-card"; - card.innerHTML = ` -
-

${item.preference_id}

- ${item.preference_strength || "-"} -
-

${item.left_revision_id} vs ${item.right_revision_id}\npreferred ${item.preferred_revision_id}\nissues ${(item.linked_issue_codes || []).join(" / ") || "-"}\nnotes ${item.freeform_notes || "-"}

- `; - els.opsPreferenceSamples.appendChild(card); + (opsState.opsPreferenceSamples || []).slice(0, 5).forEach((item) => { + const card = createListCard({ + title: `偏好样本 · ${item.preference_id}`, + score: item.preference_strength || "-", + body: opsSections( + opsSection("对比版本", [ + opsFieldLine("左侧版本", item.left_revision_id || "-"), + opsFieldLine("右侧版本", item.right_revision_id || "-"), + opsFieldLine("偏好版本", item.preferred_revision_id || "-"), + ]), + opsSection("问题与备注", [ + opsFieldLine("关联问题", opsIssueCodeList(item.linked_issue_codes || [])), + opsFieldLine("备注", item.freeform_notes || "-"), + ]) + ) + }); + dom.opsPreferenceSamples.appendChild(card); }); } - clearNode(els.opsRankingSamples); - if (!(appState.opsRankingSamples || []).length) { - clearNode(els.opsRankingSamples, "这里会显示最近采集的 ranking samples。"); + clearNode(dom.opsRankingSamples); + if (!(opsState.opsRankingSamples || []).length) { + clearNode(dom.opsRankingSamples, "这里会显示最近采集的排序样本。"); } else { - (appState.opsRankingSamples || []).slice(0, 5).forEach((item) => { - const card = document.createElement("article"); - card.className = "list-card"; - card.innerHTML = ` -
-

${item.ranking_id}

- ${item.top_revision_id || "-"} -
-

ranked ${(item.ranked_revision_ids || []).join(" > ") || "-"}\nissues ${(item.linked_issue_codes || []).join(" / ") || "-"}\nnotes ${item.freeform_notes || "-"}

- `; - els.opsRankingSamples.appendChild(card); + (opsState.opsRankingSamples || []).slice(0, 5).forEach((item) => { + const card = createListCard({ + title: `排序样本 · ${item.ranking_id}`, + score: item.top_revision_id || "-", + body: opsSections( + opsSection("排序结果", [ + opsFieldLine("排序序列", (item.ranked_revision_ids || []).join(" > ") || "-"), + ]), + opsSection("问题与备注", [ + opsFieldLine("关联问题", opsIssueCodeList(item.linked_issue_codes || [])), + opsFieldLine("备注", item.freeform_notes || "-"), + ]) + ) + }); + dom.opsRankingSamples.appendChild(card); }); } - clearNode(els.opsPairCoverageBacklog); - if (!appState.opsLearnedDataOps?.pair_coverage_backlog?.length) { - clearNode(els.opsPairCoverageBacklog, "这里会显示需要更多 revision / review 才能长出 inferred pairs 的位置。"); + clearNode(dom.opsPairCoverageBacklog); + if (!opsState.opsLearnedDataOps?.pair_coverage_backlog?.length) { + clearNode(dom.opsPairCoverageBacklog, "这里会显示需要更多版本对比与人工审阅才能形成有效偏好对的位置。"); } else { - appState.opsLearnedDataOps.pair_coverage_backlog.forEach((item) => { - const card = document.createElement("article"); - card.className = "list-card"; - card.innerHTML = ` -
-

${item.world_id} · ${item.issue_code}

- ${item.recommended_action || "-"} -
-

coverage ${item.coverage_count ?? 0}\nrecent revisions ${(item.recent_revision_ids || []).join(" / ") || "-"}\nchanged sections ${(item.changed_sections || []).join(" / ") || "-"}\nshadow next ${item.shadow_context?.recommended_next_action || "-"}

- `; - els.opsPairCoverageBacklog.appendChild(card); + opsState.opsLearnedDataOps.pair_coverage_backlog.forEach((item) => { + const card = createListCard({ + title: `配对覆盖待办 · ${opsWorldLabel(item.world_id)} · ${opsIssueCodeLabel(item.issue_code)}`, + score: item.recommended_action || "-", + body: opsSections( + opsSection("覆盖情况", [ + opsFieldLine("覆盖数", item.coverage_count ?? 0), + opsFieldLine("最近版本", (item.recent_revision_ids || []).join(" / ") || "-"), + opsFieldLine("变更片段", (item.changed_sections || []).join(" / ") || "-"), + ]), + opsSection("影子建议", [ + opsFieldLine("下一步", item.shadow_context?.recommended_next_action || "-"), + ]) + ) + }); + dom.opsPairCoverageBacklog.appendChild(card); }); } - clearNode(els.opsReviewCaptureContext); - if (!appState.opsReviewCaptureTarget) { - clearNode(els.opsReviewCaptureContext, "点击 Review Backlog 里的章节后,这里会自动填充上下文。"); + clearNode(dom.opsReviewCaptureContext); + if (!opsState.opsReviewCaptureTarget) { + clearNode(dom.opsReviewCaptureContext, "点击审核待办里的章节后,这里会自动填充上下文。"); } else { - const target = appState.opsReviewCaptureTarget; - const card = document.createElement("article"); - card.className = "list-card"; - card.innerHTML = ` -
-

${target.chapter_id}

- ${target.recommended_action || "-"} -
-

world ${target.world_id}\nworld_version ${target.world_version_id}\nsession ${target.session_id || "-"}\nissues ${(target.issue_codes || []).join(" / ") || "-"}\nshadow ${(target.shadow_context?.preferred_shadow_candidate || "neither")} · ${target.shadow_context?.recommended_next_action || "-"}

- `; - els.opsReviewCaptureContext.appendChild(card); + const target = opsState.opsReviewCaptureTarget; + const card = createListCard({ + title: `人工审阅上下文 · ${target.chapter_id}`, + score: target.recommended_action || "-", + body: opsSections( + opsSection("章节上下文", [ + opsFieldLine("世界", opsWorldLabel(target.world_id)), + opsFieldLine("世界版本", target.world_version_id || "-"), + opsFieldLine("会话", target.session_id || "-"), + opsFieldLine("问题列表", opsIssueCodeList(target.issue_codes || [])), + ]), + opsSection("影子建议", [ + opsFieldLine("偏好候选", opsPreferredCandidateLabel(target.shadow_context?.preferred_shadow_candidate)), + opsFieldLine("下一步", target.shadow_context?.recommended_next_action || "-"), + ]) + ) + }); + dom.opsReviewCaptureContext.appendChild(card); } - clearNode(els.opsLastActionImpact); - if (!appState.opsLastActionImpact) { - clearNode(els.opsLastActionImpact, "提交一条 Human Review 后,这里会显示对 backlog / compare / next action 的即时影响。"); + clearNode(dom.opsLastActionImpact); + if (!opsState.opsLastActionImpact) { + clearNode(dom.opsLastActionImpact, "提交一条人工审阅后,这里会显示对审核待办、章节对照和下一步动作的即时影响。"); } else { - const impact = appState.opsLastActionImpact; - const card = document.createElement("article"); - card.className = "list-card"; - card.innerHTML = ` -
-

${impact.chapter_id || "-"}

- ${impact.cleared_backlog_target ? "cleared" : "updated"} -
-

world ${impact.world_id || "-"}\nworld_version ${impact.world_version_id || "-"}\nreview sample ${impact.review_sample_id || "-"}\npreferred ${impact.preferred_shadow_candidate_before || "neither"} -> ${impact.preferred_shadow_candidate_after || "neither"}\nnext ${impact.recommended_next_action_before || "-"} -> ${impact.recommended_next_action_after || "-"}\nreview backlog ${impact.review_backlog_count_before ?? 0} -> ${impact.review_backlog_count_after ?? 0}\npair backlog ${impact.pair_backlog_count_before ?? 0} -> ${impact.pair_backlog_count_after ?? 0}\naction queue ${impact.action_queue_count_before ?? 0} -> ${impact.action_queue_count_after ?? 0}\ncleared backlog target ${impact.cleared_backlog_target ? "yes" : "no"}\nwarnings before ${(impact.warnings_before || []).join(" / ") || "-"}\nwarnings after ${(impact.warnings_after || []).join(" / ") || "-"}

- `; - els.opsLastActionImpact.appendChild(card); + const impact = opsState.opsLastActionImpact; + const card = createListCard({ + title: `最近动作影响 · ${impact.chapter_id || "-"}`, + score: impact.cleared_backlog_target ? "已清除" : "已更新", + body: opsSections( + opsSection("上下文", [ + opsFieldLine("世界", impact.world_id ? opsWorldLabel(impact.world_id) : "-"), + opsFieldLine("世界版本", impact.world_version_id || "-"), + opsFieldLine("审阅样本", impact.review_sample_id || "-"), + ]), + opsSection("变化结果", [ + opsFieldLine("偏好候选", `${opsPreferredCandidateLabel(impact.preferred_shadow_candidate_before)} → ${opsPreferredCandidateLabel(impact.preferred_shadow_candidate_after)}`), + opsFieldLine("下一步", `${impact.recommended_next_action_before || "-"} → ${impact.recommended_next_action_after || "-"}`), + opsFieldLine("审阅待补样", `${impact.review_backlog_count_before ?? 0} → ${impact.review_backlog_count_after ?? 0}`), + opsFieldLine("配对覆盖待补样", `${impact.pair_backlog_count_before ?? 0} → ${impact.pair_backlog_count_after ?? 0}`), + opsFieldLine("行动队列", `${impact.action_queue_count_before ?? 0} → ${impact.action_queue_count_after ?? 0}`), + opsFieldLine("是否清除目标", opsBooleanLabel(impact.cleared_backlog_target)), + ]), + opsSection("告警变化", [ + opsFieldLine("动作前", (impact.warnings_before || []).join(" / ") || "-"), + opsFieldLine("动作后", (impact.warnings_after || []).join(" / ") || "-"), + ]) + ) + }); + dom.opsLastActionImpact.appendChild(card); } } @@ -2243,12 +3517,13 @@ function renderOpsSurface(scopes = OPS_REFRESH_SCOPE_ALL) { const renderInvestigation = renderAll || scopeSet.has("investigation"); const renderLearned = renderAll || scopeSet.has("learned"); - els.opsPendingCount.textContent = String(appState.opsReviewQueue.length); - els.opsPublishedWorlds.textContent = String( - appState.opsWorldStatuses.filter((status) => Boolean(status.published_version)).length + const reviewHubSummary = opsState.opsReviewHub?.summary || {}; + dom.opsPendingCount.textContent = String(reviewHubSummary.actionable_count ?? opsState.opsReviewQueue.length); + dom.opsPublishedWorlds.textContent = String( + opsState.opsWorldStatuses.filter((status) => Boolean(status.published_version)).length ); - const totalCost = appState.opsMeters.reduce((sum, item) => sum + Number(item.estimated_cost || 0), 0); - els.opsTotalCost.textContent = `¥${totalCost.toFixed(2)}`; + const totalCost = opsState.opsMeters.reduce((sum, item) => sum + Number(item.estimated_cost || 0), 0); + dom.opsTotalCost.textContent = `¥${totalCost.toFixed(2)}`; if (renderNavigation) { renderOpsNavigationSection(); @@ -2272,3 +3547,20 @@ function renderOpsSurface(scopes = OPS_REFRESH_SCOPE_ALL) { renderOpsLearnedSection(); } } + + return { + summarizeReviewTimelineEntry, + formatSignedDelta, + summarizeRollbackEntry, + summarizeQualityTrendEntry, + summarizeReleaseBlocker, + renderOpsNavigationSection, + renderOpsReviewReleaseSection, + renderOpsRuntimeSection, + renderOpsJobsSection, + renderOpsAccountSection, + renderOpsInvestigationSection, + renderOpsLearnedSection, + renderOpsSurface + }; +})(); diff --git a/src/narrativeos/web/ops_runtime.js b/src/narrativeos/web/ops_runtime.js new file mode 100644 index 0000000..2fcba39 --- /dev/null +++ b/src/narrativeos/web/ops_runtime.js @@ -0,0 +1,396 @@ +// Ops runtime handlers extracted from app.js so shell orchestration stays thin. + +var OpsRuntime = (() => { + const dom = OpsDOM; + const { + api, + setBusy, + parseIssueCodes + } = UIShared; + const { + currentOpsNavigationContext, + syncOpsNavigationContext, + refreshOpsReleaseWorkspace, + refreshOpsCrossPackQuality, + refreshOpsAlerts, + refreshOpsSurface, + refreshOpsReleaseFlow, + refreshOpsLearnedFlow + } = OpsRefreshRuntime; + const { renderOpsSurface } = OpsRenderRuntime; + const { + submitPromotionDecision, + submitRerankerPromotionDecision, + submitProviderRollout, + runDataIntegrityRepair, + submitAssistedGateConfig, + submitAssistedRerankConfig, + createGovernanceCase, + updateGovernanceCaseStatus, + applyGovernanceRestriction, + releaseGovernanceRestriction, + assignGovernanceCase, + addGovernanceEvidence, + refreshGovernanceAuditExport, + createRuntimeBackup, + restoreRuntimeBackup, + runRecoveryDrill, + requestRuntimeRestore, + approveRuntimeRestore, + revokeRuntimeRestore, + executeRuntimeRestore, + retryAsyncJob, + resumeAsyncJob, + recoverAsyncJobIncidents, + enforceAsyncJobRetention, + runColdStartRecoveryDrill, + exportAsyncJobHandoffBundle, + acknowledgeAsyncJob, + shipRemoteArtifacts, + escalateHandoffSla, + enqueueNotificationRetry, + processNotificationRetry, + runLearnedTraining, + grantOpsSubscription, + changeOpsSubscriptionState, + grantOpsWallet, + debitOpsWallet, + reconcileOpsSubscription, + retryOpsSubscriptionPayment, + replayOpsBillingEvent, + updateSelectedOpsAlertStatus, + openSelectedOpsAlertInvestigation, + followOpsNavigationRecommendation, + runOpsInvestigation, + exportOpsInvestigationTrace, + revokeOpsEntitlement + } = OpsActionsRuntime; + + let opsEventsBound = false; + let opsRuntimeInitialized = false; + +async function submitOpsReviewCapture() { + if (!opsState.opsReviewCaptureTarget) { + alert("先从 Review Backlog 里选择一条章节。"); + return; + } + const reviewerId = dom.opsReviewerId?.value.trim() || "ops_web"; + const issueCodes = parseIssueCodes(dom.opsReviewIssueCodes?.value || ""); + if (!reviewerId || !issueCodes.length) { + alert("请至少填写 reviewer_id 和 issue codes。"); + return; + } + const restore = setBusy(dom.opsSubmitReviewCapture, "提交中…"); + try { + const result = await api("/v1/ops/review-samples", { + method: "POST", + body: JSON.stringify({ + chapter_id: opsState.opsReviewCaptureTarget.chapter_id, + world_id: opsState.opsReviewCaptureTarget.world_id, + world_version_id: opsState.opsReviewCaptureTarget.world_version_id, + session_id: opsState.opsReviewCaptureTarget.session_id, + reviewer_id: reviewerId, + score_overall: Number(dom.opsReviewScore?.value || 0.65), + issue_codes: issueCodes, + freeform_notes: dom.opsReviewNotes?.value || "", + would_continue: Boolean(dom.opsReviewWouldContinue?.checked), + would_pay: Boolean(dom.opsReviewWouldPay?.checked), + }), + }); + opsState.opsLastActionImpact = result.impact_receipt || null; + opsState.opsReviewCaptureTarget = null; + if (dom.opsReviewNotes) dom.opsReviewNotes.value = ""; + if (dom.opsReviewIssueCodes) dom.opsReviewIssueCodes.value = ""; + await refreshOpsSurface({ preserveLastActionImpact: true }); + } catch (error) { + alert(`提交 Human Review 失败:${error.message}`); + } finally { + restore(); + } +} + +async function submitOpsPreferenceCapture() { + if (!opsState.opsReviewCaptureTarget) { + alert("先从 Review Backlog 里选择一条章节,作为 preference 的上下文。"); + return; + } + const reviewerId = dom.opsReviewerId?.value.trim() || "ops_web"; + const leftRevisionId = dom.opsPreferenceLeftRevisionId?.value.trim() || ""; + const rightRevisionId = dom.opsPreferenceRightRevisionId?.value.trim() || ""; + const preferredRevisionId = dom.opsPreferencePreferredRevisionId?.value.trim() || ""; + if (!reviewerId || !leftRevisionId || !rightRevisionId || !preferredRevisionId) { + alert("请填写 reviewer_id、left/right revision id 和 preferred revision id。"); + return; + } + const restore = setBusy(dom.opsSubmitPreferenceCapture, "提交中…"); + try { + await api("/v1/ops/preference-samples", { + method: "POST", + body: JSON.stringify({ + world_id: opsState.opsReviewCaptureTarget.world_id, + world_version_id: opsState.opsReviewCaptureTarget.world_version_id, + chapter_id: opsState.opsReviewCaptureTarget.chapter_id, + session_id: opsState.opsReviewCaptureTarget.session_id, + reviewer_id: reviewerId, + left_revision_id: leftRevisionId, + right_revision_id: rightRevisionId, + preferred_revision_id: preferredRevisionId, + freeform_notes: dom.opsPreferenceNotes?.value || "", + linked_issue_codes: parseIssueCodes(dom.opsReviewIssueCodes?.value || ""), + preference_strength: dom.opsPreferenceStrength?.value || "medium", + }), + }); + if (dom.opsPreferenceNotes) dom.opsPreferenceNotes.value = ""; + await refreshOpsLearnedFlow(); + } catch (error) { + alert(`提交 Preference 失败:${error.message}`); + } finally { + restore(); + } +} + +async function submitOpsRankingCapture() { + if (!opsState.opsReviewCaptureTarget) { + alert("先从 Review Backlog 里选择一条章节,作为 ranking 的上下文。"); + return; + } + const reviewerId = dom.opsReviewerId?.value.trim() || "ops_web"; + const rankedRevisionIds = (dom.opsRankingRevisionIds?.value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + if (!reviewerId || rankedRevisionIds.length < 2) { + alert("请填写 reviewer_id,且 ranked revision ids 至少包含两个。"); + return; + } + const restore = setBusy(dom.opsSubmitRankingCapture, "提交中…"); + try { + await api("/v1/ops/ranking-samples", { + method: "POST", + body: JSON.stringify({ + world_id: opsState.opsReviewCaptureTarget.world_id, + world_version_id: opsState.opsReviewCaptureTarget.world_version_id, + chapter_id: opsState.opsReviewCaptureTarget.chapter_id, + session_id: opsState.opsReviewCaptureTarget.session_id, + reviewer_id: reviewerId, + ranked_revision_ids: rankedRevisionIds, + freeform_notes: dom.opsRankingNotes?.value || "", + linked_issue_codes: parseIssueCodes(dom.opsReviewIssueCodes?.value || ""), + }), + }); + if (dom.opsRankingNotes) dom.opsRankingNotes.value = ""; + if (dom.opsRankingRevisionIds) dom.opsRankingRevisionIds.value = ""; + await refreshOpsLearnedFlow(); + } catch (error) { + alert(`提交 Ranking 失败:${error.message}`); + } finally { + restore(); + } +} + +function bindOpsEvents() { + if (opsEventsBound) return; + opsEventsBound = true; + + dom.opsAccountId?.addEventListener("change", () => { + if (dom.opsInvestigationAccountId && !dom.opsInvestigationAccountId.value.trim()) { + dom.opsInvestigationAccountId.value = dom.opsAccountId.value.trim(); + } + if (dom.opsAlertAccountId && !dom.opsAlertAccountId.value.trim()) { + dom.opsAlertAccountId.value = dom.opsAccountId.value.trim(); + } + if (dom.opsNavAccountId && !dom.opsNavAccountId.value.trim()) { + dom.opsNavAccountId.value = dom.opsAccountId.value.trim(); + } + }); + dom.opsAlertAccountId?.addEventListener("change", async () => { + if (dom.opsNavAccountId && !dom.opsNavAccountId.value.trim()) { + dom.opsNavAccountId.value = dom.opsAlertAccountId.value.trim(); + } + await refreshOpsAlerts(); + renderOpsSurface(); + }); + dom.opsAlertStatusFilter?.addEventListener("change", async () => { + await refreshOpsAlerts(); + renderOpsSurface(); + }); + dom.opsAlertSeverityFilter?.addEventListener("change", async () => { + await refreshOpsAlerts(); + renderOpsSurface(); + }); + dom.opsRefresh?.addEventListener("click", refreshOpsSurface); + dom.opsRefreshCrossPackQuality?.addEventListener("click", async () => { + try { + await refreshOpsCrossPackQuality({ + validateStrategyBundle: Boolean(dom.opsCrossPackValidateStrategyBundle?.checked), + strategyBundleId: (dom.opsCrossPackStrategyBundleId?.value || "").trim(), + weakestLimit: (dom.opsCrossPackWeakestLimit?.value || "").trim() || "3", + }); + renderOpsSurface(); + } catch (error) { + alert(`刷新策略包验证失败:${error.message}`); + } + }); + dom.opsSyncNavigation?.addEventListener("click", async () => { + try { + syncOpsNavigationContext(currentOpsNavigationContext(), { preserveExisting: false }); + await refreshOpsSurface({ scopes: ["account", "review_release", "alerts", "navigation"] }); + } catch (error) { + alert(`同步 Ops context 失败:${error.message}`); + } + }); + dom.opsFollowRecommendation?.addEventListener("click", async () => { + try { + await followOpsNavigationRecommendation(); + } catch (error) { + alert(`执行推荐升级路径失败:${error.message}`); + } + }); + dom.opsNavAccountId?.addEventListener("change", () => { + if (dom.opsAccountId) { + dom.opsAccountId.value = dom.opsNavAccountId.value.trim(); + } + }); + dom.opsNavWorldId?.addEventListener("change", () => { + opsState.selectedOpsWorldId = (dom.opsNavWorldId?.value || "").trim() || null; + if (dom.opsReleaseWorldId) { + dom.opsReleaseWorldId.value = dom.opsNavWorldId.value.trim(); + } + }); + dom.opsNavCaseId?.addEventListener("change", () => { + if (dom.opsGovernanceCaseId) { + dom.opsGovernanceCaseId.value = dom.opsNavCaseId.value.trim(); + } + }); + dom.opsRefreshReleaseWorkspace?.addEventListener("click", async () => { + try { + await refreshOpsReleaseWorkspace(); + renderOpsSurface(); + } catch (error) { + alert(`刷新 release workspace 失败:${error.message}`); + } + }); + dom.opsReleaseWorldId?.addEventListener("change", async () => { + opsState.selectedOpsWorldId = (dom.opsReleaseWorldId?.value || "").trim() || null; + if (dom.opsNavWorldId) { + dom.opsNavWorldId.value = dom.opsReleaseWorldId.value.trim(); + } + await refreshOpsReleaseWorkspace(); + renderOpsSurface(); + }); + dom.opsCreateRuntimeBackup?.addEventListener("click", createRuntimeBackup); + dom.opsRestoreRuntimeBackup?.addEventListener("click", restoreRuntimeBackup); + dom.opsRunRecoveryDrill?.addEventListener("click", runRecoveryDrill); + dom.opsRequestRuntimeRestore?.addEventListener("click", requestRuntimeRestore); + dom.opsApproveRuntimeRestore?.addEventListener("click", approveRuntimeRestore); + dom.opsRevokeRuntimeRestore?.addEventListener("click", revokeRuntimeRestore); + dom.opsExecuteRuntimeRestore?.addEventListener("click", executeRuntimeRestore); + dom.opsRunDataIntegrityDryRun?.addEventListener("click", () => runDataIntegrityRepair(false)); + dom.opsApplyDataIntegrityRepair?.addEventListener("click", () => runDataIntegrityRepair(true)); + dom.opsRetryAsyncJob?.addEventListener("click", retryAsyncJob); + dom.opsResumeAsyncJob?.addEventListener("click", resumeAsyncJob); + dom.opsRecoverAsyncJobs?.addEventListener("click", recoverAsyncJobIncidents); + dom.opsEnforceAsyncRetention?.addEventListener("click", enforceAsyncJobRetention); + dom.opsRunColdStartDrill?.addEventListener("click", runColdStartRecoveryDrill); + dom.opsExportHandoffBundle?.addEventListener("click", exportAsyncJobHandoffBundle); + dom.opsAcknowledgeAsyncJob?.addEventListener("click", acknowledgeAsyncJob); + dom.opsShipRemoteArtifacts?.addEventListener("click", shipRemoteArtifacts); + dom.opsEscalateHandoffSla?.addEventListener("click", escalateHandoffSla); + dom.opsEnqueueNotificationRetry?.addEventListener("click", enqueueNotificationRetry); + dom.opsProcessNotificationRetry?.addEventListener("click", processNotificationRetry); + dom.opsGrantSubscription?.addEventListener("click", grantOpsSubscription); + dom.opsChangeSubscriptionState?.addEventListener("click", changeOpsSubscriptionState); + dom.opsGrantWallet?.addEventListener("click", grantOpsWallet); + dom.opsDebitWallet?.addEventListener("click", debitOpsWallet); + dom.opsRevokeEntitlement?.addEventListener("click", revokeOpsEntitlement); + dom.opsReconcileSubscription?.addEventListener("click", reconcileOpsSubscription); + dom.opsRetrySubscriptionPayment?.addEventListener("click", retryOpsSubscriptionPayment); + dom.opsReplayBillingEvent?.addEventListener("click", replayOpsBillingEvent); + dom.opsRefreshAlerts?.addEventListener("click", async () => { + try { + await refreshOpsAlerts(); + renderOpsSurface(); + } catch (error) { + alert(`刷新 alerts 失败:${error.message}`); + } + }); + dom.opsAcknowledgeAlert?.addEventListener("click", async () => { + try { + await updateSelectedOpsAlertStatus("acknowledged"); + } catch (error) { + alert(`ack alert 失败:${error.message}`); + } + }); + dom.opsResolveAlert?.addEventListener("click", async () => { + try { + await updateSelectedOpsAlertStatus("resolved"); + } catch (error) { + alert(`resolve alert 失败:${error.message}`); + } + }); + dom.opsProviderCandidateCanary?.addEventListener("click", () => submitProviderRollout("candidate", "canary")); + dom.opsProviderCandidateActivate?.addEventListener("click", () => submitProviderRollout("candidate", "activate")); + dom.opsProviderCandidateRollback?.addEventListener("click", () => submitProviderRollout("candidate", "rollback")); + dom.opsProviderRendererCanary?.addEventListener("click", () => submitProviderRollout("renderer", "canary")); + dom.opsProviderRendererActivate?.addEventListener("click", () => submitProviderRollout("renderer", "activate")); + dom.opsProviderRendererRollback?.addEventListener("click", () => submitProviderRollout("renderer", "rollback")); + dom.opsOpenAlertInvestigation?.addEventListener("click", async () => { + try { + await openSelectedOpsAlertInvestigation(); + } catch (error) { + alert(`打开 alert investigation 失败:${error.message}`); + } + }); + dom.opsRunInvestigation?.addEventListener("click", async () => { + try { + await runOpsInvestigation(); + } catch (error) { + alert(`运行统一排查失败:${error.message}`); + } + }); + dom.opsExportInvestigationTrace?.addEventListener("click", async () => { + try { + await exportOpsInvestigationTrace(); + } catch (error) { + alert(`导出 investigation trace 失败:${error.message}`); + } + }); + dom.opsCreateGovernanceCase?.addEventListener("click", createGovernanceCase); + dom.opsAssignGovernanceCase?.addEventListener("click", assignGovernanceCase); + dom.opsAddGovernanceEvidence?.addEventListener("click", addGovernanceEvidence); + dom.opsUpdateGovernanceCase?.addEventListener("click", updateGovernanceCaseStatus); + dom.opsApplyGovernanceRestriction?.addEventListener("click", applyGovernanceRestriction); + dom.opsReleaseGovernanceRestriction?.addEventListener("click", releaseGovernanceRestriction); + dom.opsExportGovernanceAudit?.addEventListener("click", refreshGovernanceAuditExport); + dom.opsSubmitReviewCapture?.addEventListener("click", submitOpsReviewCapture); + dom.opsSubmitPreferenceCapture?.addEventListener("click", submitOpsPreferenceCapture); + dom.opsSubmitRankingCapture?.addEventListener("click", submitOpsRankingCapture); + dom.opsApprovePromotion?.addEventListener("click", () => submitPromotionDecision("approve")); + dom.opsRevokePromotion?.addEventListener("click", () => submitPromotionDecision("revoke")); + dom.opsApproveRerankerPromotion?.addEventListener("click", () => submitRerankerPromotionDecision("approve")); + dom.opsRevokeRerankerPromotion?.addEventListener("click", () => submitRerankerPromotionDecision("revoke")); + dom.opsSetAssistedShadow?.addEventListener("click", () => submitAssistedGateConfig("shadow_only", true)); + dom.opsSetAssistedActive?.addEventListener("click", () => submitAssistedGateConfig("assisted_gate", true)); + dom.opsDisableAssistedGate?.addEventListener("click", () => submitAssistedGateConfig("shadow_only", false)); + dom.opsSetAssistedRerankShadow?.addEventListener("click", () => submitAssistedRerankConfig("shadow_only", true)); + dom.opsSetAssistedRerankActive?.addEventListener("click", () => submitAssistedRerankConfig("assisted_rerank", true)); + dom.opsDisableAssistedRerank?.addEventListener("click", () => submitAssistedRerankConfig("shadow_only", false)); + dom.opsRunEvaluatorTraining?.addEventListener("click", () => runLearnedTraining(["evaluator"])); + dom.opsRunRerankerTraining?.addEventListener("click", () => runLearnedTraining(["reranker"])); + dom.opsRunBothTraining?.addEventListener("click", () => runLearnedTraining(["evaluator", "reranker"])); +} + +function initializeOpsRuntime() { + if (opsRuntimeInitialized) return; + opsRuntimeInitialized = true; + bindOpsEvents(); +} + + return { + bindOpsEvents, + initializeOpsRuntime, + submitOpsReviewCapture, + submitOpsPreferenceCapture, + submitOpsRankingCapture + }; +})(); diff --git a/src/narrativeos/web/ops_shared.js b/src/narrativeos/web/ops_shared.js new file mode 100644 index 0000000..4826c39 --- /dev/null +++ b/src/narrativeos/web/ops_shared.js @@ -0,0 +1,367 @@ +// Ops shared helpers extracted from app.js so Ops actions and renderers can share one boundary. + +var OpsShared = (() => { + const dom = OpsDOM; + const { + api, + formatPercent + } = UIShared; + +const OPS_STATUS_LABELS = { + open: "待处理", + unread: "未读", + active: "处理中", + acknowledged: "已确认", + resolved: "已解决", + dismissed: "已关闭", + escalated: "已升级", + in_review: "审核中", + approved: "已通过", + published: "已发布", + rolled_back: "已回滚", + publish_blocked: "发布受阻", + active_restriction: "限制生效中", + released: "已释放", + completed: "已完成", + queued: "排队中", + running: "运行中", + failed: "失败", + paused: "已暂停", + canceled: "已取消", + expired: "已过期", + trialing: "试用中", + shadow_only: "仅影子模式", + assisted_gate: "辅助门控", + assisted_rerank: "辅助重排", + canary: "灰度中", + activate: "全量启用", + rollback: "回滚", + pending: "待处理", + invalid: "配置异常", + valid: "配置正常", +}; + +const OPS_PROVIDER_LABELS = { + stripe: "Stripe", + web_stub: "网页支付测试", + ops_manual: "运营手动", + default: "默认通道", + global: "全局", + candidate: "候选链路", + renderer: "渲染链路", +}; + +const OPS_TRACK_LABELS = { + evaluator: "评估器", + reranker: "重排器", + candidate: "候选链路", + renderer: "渲染链路", +}; + +const OPS_SCOPE_LABELS = { + global: "全局", + world: "世界", + account: "账户", + chapter: "章节", +}; + +const OPS_ACTION_MODE_LABELS = { + execute: "执行", + navigate: "跳转", + prefill: "填充", + open_detail: "打开详情", +}; + +const OPS_ISSUE_LABELS = { + Q01: "Q01 工程泄漏", + Q02: "Q02 元叙事泄漏", + Q03: "Q03 重复", + Q04: "Q04 解释过多", + Q05: "Q05 场景细节不足", + Q06: "Q06 人物不稳定", + Q07: "Q07 因果断裂", + Q08: "Q08 选项差异弱", + Q09: "Q09 节奏失败 / 过早收束", + Q10: "Q10 产品连续性失败", +}; + +const OPS_WORLD_LABELS = { + jade_court_exam: "玉阙春闱(职责线)", + jade_court_romance: "玉阙春闱(情感线)", + urban_mystery_lotus_lane: "莲巷迷案", + xianxia_forgotten_vow: "失誓仙途", + synthetic_min_pack: "合成最小包", +}; + +function reviewStatusLabel(status) { + return { + submitted: "已提交审核", + approved: "审核通过", + published: "已发布", + rolled_back: "已回滚", + publish_blocked: "发布被阻止", + }[status] || status; +} + +function opsStatusLabel(status) { + return OPS_STATUS_LABELS[String(status || "").trim()] || reviewStatusLabel(status) || status || "-"; +} + +function opsProviderLabel(provider) { + return OPS_PROVIDER_LABELS[String(provider || "").trim()] || provider || "-"; +} + +function opsTrackLabel(track) { + return OPS_TRACK_LABELS[String(track || "").trim()] || track || "-"; +} + +function opsScopeLabel(scope) { + return OPS_SCOPE_LABELS[String(scope || "").trim()] || scope || "-"; +} + +function opsActionModeLabel(mode) { + return OPS_ACTION_MODE_LABELS[String(mode || "").trim()] || mode || "-"; +} + +function opsIssueCodeLabel(issueCode) { + return OPS_ISSUE_LABELS[String(issueCode || "").trim()] || issueCode || "-"; +} + +function opsIssueCodeList(issueCodes) { + return (issueCodes || []).map((item) => opsIssueCodeLabel(item)).join(" / ") || "-"; +} + +function opsWorldLabel(worldId) { + const raw = String(worldId || "").trim(); + if (!raw) return "-"; + if (OPS_WORLD_LABELS[raw]) return OPS_WORLD_LABELS[raw]; + if (raw.startsWith("smoke_draft_")) return "烟雾测试草稿"; + return raw; +} + +function opsBooleanLabel(value) { + return value ? "是" : "否"; +} + +function opsNumericValue(value, digits = 0) { + if (value === undefined || value === null || value === "") return "-"; + const numeric = Number(value); + if (!Number.isFinite(numeric)) return String(value); + return numeric.toFixed(digits); +} + +function opsLatencyValue(value) { + const numeric = opsNumericValue(value, 1); + return numeric === "-" ? "-" : `${numeric}ms`; +} + +function opsCostValue(value, digits = 3) { + return opsNumericValue(value, digits); +} + +function opsWorldList(worldIds) { + return (worldIds || []).map((item) => opsWorldLabel(item)).join(" / ") || "-"; +} + +function opsTrackList(tracks) { + return (tracks || []).map((item) => opsTrackLabel(item)).join(" / ") || "-"; +} + +function opsPreferredCandidateLabel(candidate) { + if (!candidate || String(candidate).trim() === "neither") return "均不推荐"; + return opsTrackLabel(candidate); +} + +function opsRolloutSummary(status, bucketMatch) { + const statusLabel = opsStatusLabel(status || "-"); + if (bucketMatch === undefined || bucketMatch === null) return statusLabel; + return `${statusLabel} · ${bucketMatch ? "命中分桶" : "未命中分桶"}`; +} + +function opsTargetLabel(targetType, targetId) { + return `${targetType || "-"}:${targetId || "-"}`; +} + +function opsFieldLine(label, value) { + return `${label} ${value === undefined || value === null || value === "" ? "-" : value}`; +} + +function opsPairsLine(label, pairs, valueFormatter = (value) => value) { + const text = Object.entries(pairs || {}) + .map(([key, value]) => `${valueFormatter(key)}=${value}`) + .join(" / "); + return opsFieldLine(label, text || "-"); +} + +function summarizeChecklistEvidence(evidence) { + if (!evidence || typeof evidence !== "object") return "-"; + const parts = []; + if (evidence.cross_pack_pass_rate !== undefined && evidence.cross_pack_pass_rate !== null) { + parts.push(`跨包通过率 ${Number(evidence.cross_pack_pass_rate || 0).toFixed(3)}`); + } + if (evidence.cross_pack_pass_rate_delta !== undefined && evidence.cross_pack_pass_rate_delta !== null) { + parts.push(`变化 ${Number(evidence.cross_pack_pass_rate_delta || 0).toFixed(3)}`); + } + if (evidence.block_rate !== undefined && evidence.block_rate !== null) { + parts.push(`阻塞率 ${formatPercent(evidence.block_rate)}`); + } + if (evidence.max_prose_leak_rate !== undefined && evidence.max_prose_leak_rate !== null) { + parts.push(`最高泄漏率 ${Number(evidence.max_prose_leak_rate || 0).toFixed(3)}`); + } + if (Array.isArray(evidence.top_failing_pack_ids) && evidence.top_failing_pack_ids.length) { + parts.push(`薄弱世界 ${evidence.top_failing_pack_ids.map((item) => opsWorldLabel(item)).join(" / ")}`); + } + if (Array.isArray(evidence.regressions) && evidence.regressions.length) { + parts.push(`回退项 ${evidence.regressions.join(" / ")}`); + } + if (Array.isArray(evidence.leaking_worlds) && evidence.leaking_worlds.length) { + parts.push(`泄漏世界 ${evidence.leaking_worlds.map((item) => `${opsWorldLabel(item.world_id)}:${Number(item.prose_leak_rate || 0).toFixed(3)}`).join(" / ")}`); + } + if (evidence.latest_decision) { + parts.push(`最近判断 ${opsStatusLabel(evidence.latest_decision)}`); + } + if (evidence.present !== undefined) { + parts.push(`是否存在 ${opsBooleanLabel(evidence.present)}`); + } + if (evidence.completed_chapters !== undefined && evidence.completed_chapters !== null) { + parts.push(`章节数 ${evidence.completed_chapters}`); + } + return parts.join(" · ") || JSON.stringify(evidence); +} + +function applySupportPrefill(prefill = {}) { + if (prefill.account_id && dom.opsAccountId) { + dom.opsAccountId.value = prefill.account_id; + } + if (prefill.wallet_type && dom.opsWalletType) { + dom.opsWalletType.value = prefill.wallet_type; + } + if (prefill.amount !== undefined && prefill.amount !== null && dom.opsWalletAmount) { + dom.opsWalletAmount.value = String(prefill.amount); + } + if (prefill.tier_id && dom.opsTierId) { + dom.opsTierId.value = prefill.tier_id; + } + if (prefill.subscription_status && dom.opsSubscriptionStatus) { + dom.opsSubscriptionStatus.value = prefill.subscription_status; + } + if (prefill.entitlement_id && dom.opsEntitlementId) { + dom.opsEntitlementId.value = prefill.entitlement_id; + } + if (prefill.entitlement_reason && dom.opsEntitlementReason) { + dom.opsEntitlementReason.value = prefill.entitlement_reason; + } +} + +function applyGovernanceCasePrefill(prefill = {}) { + if (prefill.account_id && dom.opsAccountId) { + dom.opsAccountId.value = prefill.account_id; + } + if (prefill.case_id && dom.opsGovernanceCaseId) { + dom.opsGovernanceCaseId.value = prefill.case_id; + } + if (prefill.case_type && dom.opsGovernanceCaseType) { + dom.opsGovernanceCaseType.value = prefill.case_type; + } + if (prefill.target_type && dom.opsGovernanceTargetType) { + dom.opsGovernanceTargetType.value = prefill.target_type; + } + if (prefill.target_id && dom.opsGovernanceTargetId) { + dom.opsGovernanceTargetId.value = prefill.target_id; + } + if (prefill.severity && dom.opsGovernanceSeverity) { + dom.opsGovernanceSeverity.value = prefill.severity; + } + if (prefill.reviewer_id && dom.opsGovernanceReviewerId) { + dom.opsGovernanceReviewerId.value = prefill.reviewer_id; + } + if (prefill.owner_id && dom.opsGovernanceOwnerId) { + dom.opsGovernanceOwnerId.value = prefill.owner_id; + } + if (prefill.summary && dom.opsGovernanceSummaryInput) { + dom.opsGovernanceSummaryInput.value = prefill.summary; + } + if (prefill.description && dom.opsGovernanceNotes) { + dom.opsGovernanceNotes.value = prefill.description; + } + if (prefill.status && dom.opsGovernanceStatus) { + dom.opsGovernanceStatus.value = prefill.status; + } + if (prefill.due_at && dom.opsGovernanceDueAt) { + dom.opsGovernanceDueAt.value = prefill.due_at; + } + if (prefill.disposition && dom.opsGovernanceDisposition) { + dom.opsGovernanceDisposition.value = prefill.disposition; + } + if (prefill.policy_labels && dom.opsGovernancePolicyLabels) { + dom.opsGovernancePolicyLabels.value = Array.isArray(prefill.policy_labels) ? prefill.policy_labels.join(", ") : String(prefill.policy_labels); + } +} + +async function openLearnedWorldDetail(worldId) { + opsState.opsLearnedDetail = await api(`/v1/ops/learned-dashboard/worlds/${worldId}`); + OpsRenderRuntime.renderOpsSurface(["learned"]); +} + +async function openLearnedIssueDetail(issueCode) { + opsState.opsLearnedDetail = await api(`/v1/ops/learned-dashboard/issues/${issueCode}`); + OpsRenderRuntime.renderOpsSurface(["learned"]); +} + +function selectReviewBacklogItem(item) { + opsState.opsReviewCaptureTarget = item; + if (dom.opsReviewIssueCodes) { + dom.opsReviewIssueCodes.value = (item.issue_codes || []).join(","); + } + if (dom.opsReviewNotes) { + dom.opsReviewNotes.value = item.summary || ""; + } + if (dom.opsReviewScore) { + dom.opsReviewScore.value = item.score_overall !== null && item.score_overall !== undefined + ? Number(item.score_overall).toFixed(2) + : "0.65"; + } + if (dom.opsReviewWouldContinue) { + dom.opsReviewWouldContinue.checked = item.decision !== "block"; + } + if (dom.opsReviewWouldPay) { + dom.opsReviewWouldPay.checked = item.decision === "pass"; + } + if (dom.opsPreferenceNotes) { + dom.opsPreferenceNotes.value = item.summary || ""; + } + if (dom.opsRankingNotes) { + dom.opsRankingNotes.value = item.summary || ""; + } + OpsRenderRuntime.renderOpsSurface(["learned"]); +} + + return { + reviewStatusLabel, + opsStatusLabel, + opsProviderLabel, + opsTrackLabel, + opsScopeLabel, + opsActionModeLabel, + opsIssueCodeLabel, + opsIssueCodeList, + opsWorldLabel, + opsWorldList, + opsTrackList, + opsPreferredCandidateLabel, + opsBooleanLabel, + opsNumericValue, + opsLatencyValue, + opsCostValue, + opsRolloutSummary, + opsTargetLabel, + opsFieldLine, + opsPairsLine, + summarizeChecklistEvidence, + applySupportPrefill, + applyGovernanceCasePrefill, + openLearnedWorldDetail, + openLearnedIssueDetail, + selectReviewBacklogItem + }; +})(); diff --git a/src/narrativeos/web/reader.js b/src/narrativeos/web/reader.js new file mode 100644 index 0000000..b50b883 --- /dev/null +++ b/src/narrativeos/web/reader.js @@ -0,0 +1,2244 @@ +// Reader UI runtime extracted from app.js to keep Reader flows isolated from shell, Author, and Ops logic. + +var ReaderRuntime = (() => { + const shellDom = ShellDOM; + const readerDom = ReaderDOM; + const CHECKOUT_CONTEXT_STORAGE_KEY = "narrativeos_reader_checkout_context"; + const CHECKOUT_CONTEXT_MAX_AGE_MS = 1000 * 60 * 60 * 12; + const { + api, + reportUiMessage, + setBusy, + clearNode, + formatTimestamp, + describeAuthError, + parseErrorDetail + } = UIShared; + const { + tierLabel, + accessReasonLabel, + worldUnlockLabel + } = ReaderAccessors; + +function activeReaderId() { + return ( + readerState.readerAuthSession?.identity?.account_id || + readerDom.readerIdInput?.value.trim() || + readerState.readerId || + "reader_demo" + ); +} + +function activeAuthoredWorkAccountId() { + return ( + authorState.authorAuthSession?.identity?.account_id || + authorState.authorAuthSession?.identity?.actor_id || + "" + ); +} + +function clearAuthoredWorkPreview() { + readerState.activeAuthoredWorkPreview = null; + readerState.worldId = readerState.currentBundle?.world_bible?.world_id || null; +} + +function persistReaderAuthSession() { + if (typeof window === "undefined") return; + if (readerState.readerAuthSession?.accessToken || (readerState.readerAuthSession?.cookieBacked && readerState.readerAuthSession?.identity)) { + window.localStorage.setItem("narrativeos_reader_auth", JSON.stringify(readerState.readerAuthSession)); + } else { + window.localStorage.removeItem("narrativeos_reader_auth"); + } +} + +function restoreReaderAuthSession() { + if (typeof window === "undefined") return; + try { + const raw = window.localStorage.getItem("narrativeos_reader_auth"); + if (!raw) { + if (readerState.readerAuthSession?.identity) return; + readerState.readerAuthSession = null; + return; + } + readerState.readerAuthSession = JSON.parse(raw); + } catch (_error) { + readerState.readerAuthSession = null; + } +} + +function persistPendingCheckoutContext(context) { + readerState.pendingCheckoutContext = context || null; + if (typeof window === "undefined") return; + if (readerState.pendingCheckoutContext) { + window.localStorage.setItem(CHECKOUT_CONTEXT_STORAGE_KEY, JSON.stringify(readerState.pendingCheckoutContext)); + } else { + window.localStorage.removeItem(CHECKOUT_CONTEXT_STORAGE_KEY); + } +} + +function restorePendingCheckoutContext() { + if (typeof window === "undefined") return; + try { + const raw = window.localStorage.getItem(CHECKOUT_CONTEXT_STORAGE_KEY); + const payload = raw ? JSON.parse(raw) : null; + const createdAt = Number(payload?.createdAt || 0); + if (!payload || !createdAt || (Date.now() - createdAt) > CHECKOUT_CONTEXT_MAX_AGE_MS) { + persistPendingCheckoutContext(null); + return; + } + readerState.pendingCheckoutContext = payload; + if (!readerState.readerAuthSession?.identity?.account_id && payload.accountId) { + readerState.readerId = payload.accountId; + if (readerDom.readerIdInput) { + readerDom.readerIdInput.value = payload.accountId; + } + } + if (!readerState.readerAuthSession?.identity?.actor_id && payload.readerActorId && readerDom.readerAuthActorId) { + readerDom.readerAuthActorId.value = payload.readerActorId; + } + } catch (_error) { + persistPendingCheckoutContext(null); + } +} + +function applyCheckoutContext(context) { + if (!context) return; + if (context.accountId && !readerState.readerAuthSession?.identity?.account_id) { + readerState.readerId = context.accountId; + if (readerDom.readerIdInput) { + readerDom.readerIdInput.value = context.accountId; + } + } + if (context.worldId) { + readerState.worldId = context.worldId; + } + if (context.readerWorkspace && shellState.activeProduct === "reader") { + shellState.readerWorkspace = context.readerWorkspace; + } + if (context.activeView) { + readerState.activeView = context.activeView; + } +} + +function renderReaderAuthStatus() { + clearNode(readerDom.readerAuthStatus); + const session = readerState.readerAuthSession; + if (!session?.identity) { + clearNode(readerDom.readerAuthStatus, "登录后,这里会显示当前账号信息与会员状态。"); + return; + } + const card = document.createElement("article"); + card.className = "list-card"; + card.innerHTML = ` +
+

${session.identity.display_name || session.identity.actor_id || "-"}

+ 已登录 +
+

账号 ${session.identity.account_id || "-"}\n显示名称 ${session.identity.display_name || session.identity.actor_id || "-"}\n邮箱验证 ${session.identity.email_verified ? "已验证" : (session.identity.verification_required ? "待验证" : "不适用")}\n登录有效期 ${session.expiresAt || session.identity.expires_at || "-"}

+ `; + readerDom.readerAuthStatus.appendChild(card); +} + +async function mirrorReaderAuthSession(session) { + readerState.readerAuthSession = session + ? { + accessToken: session.accessToken, + expiresAt: session.expiresAt, + identity: session.identity ? { ...session.identity } : null, + tokenType: session.tokenType || "bearer", + cookieBacked: Boolean(session.cookieBacked || (!session.accessToken && session.identity)), + } + : null; + persistReaderAuthSession(); + if (readerState.readerAuthSession?.identity?.account_id) { + readerState.readerId = readerState.readerAuthSession.identity.account_id; + if (readerDom.readerIdInput) { + readerDom.readerIdInput.value = readerState.readerAuthSession.identity.account_id; + } + } + if (!readerState.readerAuthSession) { + readerState.readerEntitlements = []; + renderReaderAuthStatus(); + if (typeof ShellStatusRuntime !== "undefined") { + ShellStatusRuntime.syncProductMode(); + ShellStatusRuntime.updateStatus(); + } + return; + } + renderReaderAuthStatus(); + try { + await refreshReaderEntitlements(); + } catch (_error) { + // If reader entitlements fail to refresh, keep the mirrored session so shell gating still works. + } +} + +async function hydrateReaderAuthSession() { + const existingSession = readerState.readerAuthSession; + if (!existingSession?.accessToken && !existingSession?.cookieBacked && !existingSession?.identity) { + readerState.readerAuthSession = null; + persistReaderAuthSession(); + renderReaderAuthStatus(); + if (typeof ShellStatusRuntime !== "undefined") { + ShellStatusRuntime.syncProductMode(); + ShellStatusRuntime.updateStatus(); + } + return; + } + try { + const payload = await api( + "/v1/auth/me", + existingSession?.accessToken + ? { headers: { Authorization: `Bearer ${existingSession.accessToken}` } } + : {} + ); + readerState.readerAuthSession = { + ...existingSession, + accessToken: existingSession?.accessToken || null, + identity: payload.identity, + expiresAt: payload.identity?.expires_at || existingSession?.expiresAt || null, + tokenType: existingSession?.tokenType || "bearer", + cookieBacked: !existingSession?.accessToken, + }; + persistReaderAuthSession(); + } catch (_error) { + readerState.readerAuthSession = null; + persistReaderAuthSession(); + } + if (readerState.readerAuthSession?.identity?.account_id) { + readerState.readerId = readerState.readerAuthSession.identity.account_id; + if (readerDom.readerIdInput) { + readerDom.readerIdInput.value = readerState.readerAuthSession.identity.account_id; + } + } + renderReaderAuthStatus(); + if (typeof ShellStatusRuntime !== "undefined") { + ShellStatusRuntime.syncProductMode(); + ShellStatusRuntime.updateStatus(); + } +} + +async function registerReaderAuthIdentity() { + const actorId = (readerDom.readerAuthActorId?.value || "").trim(); + const password = (readerDom.readerAuthPassword?.value || "").trim(); + if (!actorId || !password) { + reportUiMessage("请先填写邮箱和密码。", "warning"); + return; + } + try { + await api("/v1/auth/register", { + method: "POST", + body: JSON.stringify({ + actor_id: actorId, + actor_role: "reader", + password, + account_id: actorId, + display_name: (readerDom.readerAuthDisplayName?.value || "").trim() || null, + }), + }); + reportUiMessage("注册已提交。请先检查验证邮件,完成验证后再登录。", "success"); + } catch (error) { + reportUiMessage(describeAuthError(error, "注册暂时失败,请稍后重试。"), "error"); + } +} + +async function loginReaderAuthIdentity() { + const actorId = (readerDom.readerAuthActorId?.value || "").trim(); + const password = (readerDom.readerAuthPassword?.value || "").trim(); + if (!actorId || !password) { + reportUiMessage("请先填写邮箱和密码。", "warning"); + return; + } + try { + const payload = await api("/v1/auth/login", { + method: "POST", + body: JSON.stringify({ + actor_id: actorId, + password, + }), + }); + readerState.readerAuthSession = { + accessToken: payload.token?.access_token, + expiresAt: payload.token?.expires_at, + identity: payload.identity, + tokenType: payload.token?.token_type || "bearer", + }; + persistReaderAuthSession(); + if (payload.identity?.account_id) { + readerState.readerId = payload.identity.account_id; + if (readerDom.readerIdInput) { + readerDom.readerIdInput.value = payload.identity.account_id; + } + } + renderReaderAuthStatus(); + await refreshReaderEntitlements(); + if (typeof ShellStatusRuntime !== "undefined") { + ShellStatusRuntime.syncProductMode(); + ShellStatusRuntime.updateStatus(); + } + } catch (error) { + const detail = parseErrorDetail(error) || {}; + const kind = detail.code === "auth_email_unverified" ? "warning" : "error"; + reportUiMessage(describeAuthError(error, "登录暂时失败,请稍后重试。"), kind); + } +} + +async function logoutReaderAuthIdentity() { + if (!readerState.readerAuthSession?.accessToken) { + readerState.readerAuthSession = null; + persistReaderAuthSession(); + renderReaderAuthStatus(); + return; + } + try { + await api("/v1/auth/logout", { + method: "POST", + headers: { Authorization: `Bearer ${readerState.readerAuthSession.accessToken}` }, + }); + } catch (_error) { + // Clear local state even if remote revoke fails. + } + readerState.readerAuthSession = null; + persistReaderAuthSession(); + renderReaderAuthStatus(); + if (typeof ShellStatusRuntime !== "undefined") { + ShellStatusRuntime.syncProductMode(); + ShellStatusRuntime.updateStatus(); + } +} + +async function requestReaderEmailVerification() { + const actorId = (readerDom.readerAuthActorId?.value || "").trim() || readerState.readerAuthSession?.identity?.actor_id; + if (!actorId) { + reportUiMessage("请先填写邮箱。", "warning"); + return; + } + try { + const payload = await api("/v1/auth/verification/request", { + method: "POST", + body: JSON.stringify({ actor_id: actorId }), + }); + if (payload.identity) { + readerState.readerAuthSession = readerState.readerAuthSession + ? { ...readerState.readerAuthSession, identity: { ...readerState.readerAuthSession.identity, ...payload.identity } } + : readerState.readerAuthSession; + persistReaderAuthSession(); + renderReaderAuthStatus(); + } + reportUiMessage("验证邮件已发送,请检查邮箱。", "success"); + } catch (error) { + const detail = parseErrorDetail(error) || {}; + const kind = detail.reason === "verification_resend_cooldown" ? "warning" : "error"; + reportUiMessage(describeAuthError(error, "验证邮件暂时发送失败,请稍后重试。"), kind); + } +} + +async function requestReaderPasswordReset() { + const actorId = (readerDom.readerAuthActorId?.value || "").trim() || readerState.readerAuthSession?.identity?.actor_id; + if (!actorId) { + reportUiMessage("请先填写邮箱。", "warning"); + return; + } + try { + await api("/v1/auth/password-reset/request", { + method: "POST", + body: JSON.stringify({ actor_id: actorId }), + }); + reportUiMessage("密码重置邮件已发送,请检查邮箱。", "success"); + } catch (error) { + reportUiMessage(describeAuthError(error, "密码重置邮件暂时发送失败,请稍后重试。"), "error"); + } +} + +async function refreshReaderEntitlements() { + const readerId = activeReaderId(); + readerState.readerId = readerId; + if (readerDom.readerIdInput) { + readerDom.readerIdInput.value = readerId; + } + const shouldFetchEntitlements = + shellState.debug || + Boolean(readerState.sessionPaywall?.quote) || + Boolean(readerState.latestStep?.paywall?.quote) || + Boolean(readerState.readerCheckoutSession) || + readerState.continuityContract?.status === "quality_guard_failed"; + if (!shouldFetchEntitlements) { + readerState.readerSubscription = null; + readerState.readerEntitlements = []; + clearNode(readerDom.readerEntitlementList, "进入故事后,这里会显示当前会员权益与剩余点数。"); + clearNode(readerDom.readerMembershipOffers, shellState.debug ? "内部模式下会显示会员方案与支付调试入口。" : "真正需要解锁时,会在阅读流程中直接提示。"); + clearNode(readerDom.readerCheckoutStatus, shellState.debug ? "内部模式下会显示最近一次支付调试结果。" : "当前没有需要展示的支付状态。"); + updateStatus(); + return; + } + + let payload; + let subscriptionPayload; + try { + [payload, subscriptionPayload] = await Promise.all([ + api(`/v1/reader/entitlements?account_id=${encodeURIComponent(readerId)}${readerState.worldId ? `&world_id=${encodeURIComponent(readerState.worldId)}` : ""}`), + api(`/v1/reader/subscription?account_id=${encodeURIComponent(readerId)}`), + ]); + } catch (error) { + readerState.readerSubscription = null; + readerState.readerEntitlements = []; + clearNode(readerDom.readerEntitlementList, "暂时无法刷新会员权益;阅读主链路仍可继续。"); + clearNode(readerDom.readerMembershipOffers, "订阅与钱包详情暂时不可用;如遇到解锁阻塞,会在当前章节内直接提示。"); + clearNode(readerDom.readerCheckoutStatus, "最近一次支付状态暂时不可用。"); + updateStatus(); + reportUiMessage(`访问状态刷新失败:${error.message}`, shellState.debug ? "error" : "warning"); + return; + } + readerState.readerSubscription = subscriptionPayload; + readerState.readerEntitlements = payload.entitlements || []; + readerState.readerCheckoutSession = + payload.checkout_session || + payload.latest_checkout_session || + payload.recent_checkout_sessions?.[0] || + readerState.readerCheckoutSession || + null; + const credits = payload.wallets?.story_credits || readerState.readerEntitlements.find((item) => item.entitlement_type === "credits" && item.status === "active"); + const subscriber = subscriptionPayload.subscription || payload.subscription || readerState.readerEntitlements.find((item) => item.entitlement_type === "subscriber" && item.status === "active"); + const worldPass = readerState.readerEntitlements.find((item) => item.entitlement_type === "world_pass" && item.status === "active"); + const effectiveTier = subscriptionPayload.effective_tier || payload.effective_tier || subscriber?.tier_id || null; + const emailVerified = Boolean(subscriptionPayload.email_verified); + readerDom.readerEntitlementType.textContent = subscriber + ? subscriber.tier_id || "subscriber" + : worldPass + ? "world_pass" + : credits + ? credits.wallet_type || "credits" + : "trial"; + if (readerDom.readerSubscriptionStatus) { + readerDom.readerSubscriptionStatus.textContent = subscriber?.status || "inactive"; + } + readerDom.readerCreditBalance.textContent = credits ? String(Number(credits.balance || 0).toFixed(0)) : "-"; + const activePaywall = readerState.latestStep?.paywall || readerState.sessionPaywall || {}; + if (readerDom.readerWorldUnlockStatus) { + readerDom.readerWorldUnlockStatus.textContent = worldUnlockLabel(activePaywall); + } + if (readerDom.readerEntitlementReason) { + readerDom.readerEntitlementReason.textContent = accessReasonLabel(activePaywall.reason || subscriber?.reason || worldPass?.reason || credits?.reason || "trial_chapter"); + } + clearNode(readerDom.readerEntitlementList); + if (!readerState.readerEntitlements.length) { + clearNode(readerDom.readerEntitlementList, "这里会显示当前会员权益与剩余点数。"); + } else { + readerState.readerEntitlements.forEach((item) => { + const card = document.createElement("article"); + card.className = "list-card"; + card.innerHTML = ` +
+

${item.wallet_type || item.tier_id || item.entitlement_type}

+ ${item.status} +
+

适用范围 ${item.world_id ? "当前世界" : "全站"}\n剩余额度 ${item.balance ?? "-"}\n访问原因 ${accessReasonLabel(item.reason)}\n到期时间 ${item.expires_at || "-"}

+ `; + readerDom.readerEntitlementList.appendChild(card); + }); + } + if (payload.subscription) { + const card = document.createElement("article"); + card.className = "list-card"; + card.innerHTML = ` +
+

${effectiveTier ? tierLabel(effectiveTier) : (payload.subscription.tier_id || "subscription")}

+ ${payload.subscription.status || "-"} +
+

价格 ${payload.subscription.price_usd_monthly ? `$${payload.subscription.price_usd_monthly}/月` : "-"}\n当前状态 ${payload.subscription.status || "-"}\n有效会员 ${effectiveTier ? tierLabel(effectiveTier) : "-"}\n周期结束 ${payload.subscription.period_end || "-"}\n说明 ${payload.subscription.lifecycle_reason || "-"}\n建议动作 ${payload.recommended_action || "-"}\n邮箱验证 ${emailVerified ? "已验证" : "待验证"}

+ `; + readerDom.readerEntitlementList.prepend(card); + } + if (readerDom.readerAccessNote && !emailVerified && (subscriptionPayload.email_address || "").includes("@")) { + readerDom.readerAccessNote.textContent = "当前账号邮箱尚未验证。建议先完成邮箱验证,再继续真实支付与跨端恢复。"; + } + clearNode(readerDom.readerMembershipOffers); + const tiers = subscriptionPayload.tiers || []; + const providerStatus = subscriptionPayload.checkout_provider_status || payload.checkout_provider_status || {}; + if (!tiers.length) { + clearNode(readerDom.readerMembershipOffers, "这里会显示可选会员方案与解锁入口。"); + } else { + if (shellState.debug && providerStatus.provider === "stripe" && providerStatus.configured === false) { + const note = document.createElement("article"); + note.className = "list-card"; + note.innerHTML = ` +
+

支付配置未完成

+ 内部提示 +
+

当前支付通道尚未配置完成,需先补齐价格映射、密钥与回调配置,再进行真实订阅调试。

+ `; + readerDom.readerMembershipOffers.appendChild(note); + } + tiers.forEach((tier) => { + const card = document.createElement("article"); + card.className = "list-card"; + if (subscriber?.tier_id === tier.tier_id) { + card.classList.add("is-selected"); + } + const buttonLabel = subscriber?.tier_id === tier.tier_id ? "当前方案" : `开始 ${tier.tier_id}`; + card.innerHTML = ` +
+

${tierLabel(tier.tier_id)}

+ $${Number(tier.price_usd_monthly || 0).toFixed(0)}/month +
+

${tier.description || "-"}\n阅读权益 ${tier.reader_access ? "可用" : "不可用"}\n创作权益 ${tier.author_access || "无"}\n每月故事点数 ${tier.monthly_story_credits ?? 0}\n每月创作点数 ${tier.monthly_studio_credits ?? 0}\n附加能力 ${(tier.capabilities ? Object.entries(tier.capabilities).filter(([, value]) => value).map(([key]) => key).join(" / ") : "-") || "-"}

+
+ +
+ `; + const button = card.querySelector(".reader-tier-checkout"); + if (subscriber?.tier_id === tier.tier_id) { + button.disabled = true; + } else { + button.addEventListener("click", () => startReaderCheckout(tier.tier_id)); + } + readerDom.readerMembershipOffers.appendChild(card); + }); + } + clearNode(readerDom.readerCheckoutStatus); + if (!readerState.readerCheckoutSession) { + clearNode(readerDom.readerCheckoutStatus, "这里会显示最近一次支付状态。"); + } else { + const checkout = readerState.readerCheckoutSession; + const card = document.createElement("article"); + card.className = "list-card"; + card.innerHTML = ` +
+

${tierLabel(checkout.tier_id)}

+ ${checkout.status || "-"} +
+

支付状态 ${checkout.status || "-"}\n有效截止 ${checkout.expires_at || "-"}\n解锁方案 ${tierLabel(checkout.tier_id) || "-"}

+ `; + readerDom.readerCheckoutStatus.appendChild(card); + } + if (payload.lifecycle_history_summary?.latest_events?.length) { + payload.lifecycle_history_summary.latest_events.slice(0, 4).forEach((item) => { + const card = document.createElement("article"); + card.className = "list-card"; + card.innerHTML = ` +
+

${item.event_type || "-"}

+ ${item.status || "-"} +
+

${formatTimestamp(item.occurred_at)}\n状态 ${item.status || "-"}\n支付说明 ${item.reason || item.provider || "-"}

+ `; + readerDom.readerCheckoutStatus.appendChild(card); + }); + } +} + +function clearPendingCheckoutReturn() { + readerState.pendingCheckoutSessionId = null; + readerState.pendingCheckoutStatus = null; + persistPendingCheckoutContext(null); +} + +async function completePendingCheckoutReturn() { + const checkoutStatus = readerState.pendingCheckoutStatus; + const checkoutSessionId = readerState.pendingCheckoutSessionId; + const checkoutContext = readerState.pendingCheckoutContext; + if (!checkoutStatus) { + return; + } + if (checkoutStatus === "cancel") { + applyCheckoutContext(checkoutContext); + if (checkoutContext?.sessionId) { + shellState.pendingSessionId = checkoutContext.sessionId; + shellState.readerWorkspace = checkoutContext.readerWorkspace || "read"; + } + clearPendingCheckoutReturn(); + syncProductMode(); + reportUiMessage("你刚刚取消了支付;当前账号与阅读进度都已保留。", "warning"); + return; + } + if (checkoutStatus !== "success") { + clearPendingCheckoutReturn(); + syncProductMode(); + return; + } + if (!checkoutSessionId) { + clearPendingCheckoutReturn(); + syncProductMode(); + reportUiMessage("支付结果同步信息不完整,暂时无法自动更新会员状态。", "warning"); + return; + } + try { + applyCheckoutContext(checkoutContext); + const payload = await api(`/v1/reader/checkout/${encodeURIComponent(checkoutSessionId)}/complete`, { + method: "POST", + body: JSON.stringify({ + account_id: + readerState.readerAuthSession?.identity?.account_id || + checkoutContext?.accountId || + undefined, + reader_id: + readerState.readerAuthSession?.identity?.actor_id || + checkoutContext?.readerActorId || + undefined, + }), + }); + if (payload.account_id) { + readerState.readerId = payload.account_id; + if (readerDom.readerIdInput) { + readerDom.readerIdInput.value = payload.account_id; + } + if (!readerState.readerAuthSession?.identity?.actor_id && readerDom.readerAuthActorId) { + readerDom.readerAuthActorId.value = checkoutContext?.readerActorId || payload.account_id; + } + } + if (checkoutContext?.sessionId) { + shellState.pendingSessionId = checkoutContext.sessionId; + shellState.readerWorkspace = checkoutContext.readerWorkspace || "read"; + } + if (checkoutContext?.activeView) { + readerState.activeView = checkoutContext.activeView; + } + readerState.readerCheckoutSession = payload.checkout || readerState.readerCheckoutSession; + clearPendingCheckoutReturn(); + await refreshReaderEntitlements(); + syncProductMode(); + reportUiMessage( + checkoutContext?.sessionId + ? "支付已完成,当前会员状态已同步,并已准备回到你刚才那段故事。" + : "支付已完成,当前会员状态与点数已经同步。", + "success" + ); + } catch (error) { + clearPendingCheckoutReturn(); + syncProductMode(); + reportUiMessage(`支付结果同步失败:${error.message}`, "warning"); + } +} + +async function startReaderCheckout(tierId = "play_pass") { + const accountId = activeReaderId(); + const restore = setBusy(readerDom.readerStartCheckout, "创建中…"); + try { + const payload = await api("/v1/reader/checkout/start", { + method: "POST", + body: JSON.stringify({ + account_id: accountId, + tier_id: tierId, + reader_id: readerState.readerAuthSession?.identity?.actor_id || accountId, + }), + }); + readerState.readerCheckoutSession = payload.checkout; + persistPendingCheckoutContext({ + createdAt: Date.now(), + accountId, + readerActorId: readerState.readerAuthSession?.identity?.actor_id || accountId, + displayName: readerState.readerAuthSession?.identity?.display_name || readerState.readerAuthSession?.identity?.actor_id || accountId, + checkoutSessionId: payload.checkout?.checkout_session_id || payload.checkout?.session_id || null, + tierId, + worldId: readerState.worldId, + sessionId: readerState.sessionId, + readerWorkspace: shellState.readerWorkspace, + activeView: readerState.activeView, + }); + renderLatestStep(); + await refreshReaderEntitlements(); + updateStatus(); + syncProductMode(); + if (payload.checkout?.provider === "stripe" && payload.checkout?.checkout_url) { + window.open(payload.checkout.checkout_url, "_blank", "noopener,noreferrer"); + } + reportUiMessage(`已创建 ${tierLabel(tierId)} 的支付请求,系统会在当前阅读路径里继续追踪状态。`, "success"); + } catch (error) { + if (error?.detail?.code === "email_verification_required_for_billing") { + reportUiMessage("当前账号还没完成邮箱验证。先点“发送验证邮件”,完成验证后再继续支付。", "warning"); + return; + } + reportUiMessage(`创建支付请求失败:${error.message}`, "error"); + } finally { + restore(); + } +} + +async function openReaderCustomerPortal() { + const accountId = activeReaderId(); + try { + const payload = await api(`/v1/reader/subscription/${encodeURIComponent(accountId)}/portal`, { + method: "POST", + body: JSON.stringify({ return_url: `${window.location.origin}/app` }), + }); + if (payload.portal?.portal_url) { + window.open(payload.portal.portal_url, "_blank", "noopener,noreferrer"); + } + reportUiMessage("已打开订阅管理入口。", "success"); + } catch (error) { + reportUiMessage(`打开订阅管理失败:${error.message}`, "error"); + } +} + +async function retryReaderSubscriptionPayment() { + const accountId = activeReaderId(); + try { + await api(`/v1/reader/subscription/${encodeURIComponent(accountId)}/retry-payment`, { method: "POST" }); + await refreshReaderEntitlements(); + reportUiMessage("已发起支付重试。", "success"); + } catch (error) { + reportUiMessage(`重试支付失败:${error.message}`, "error"); + } +} + +function currentReaderQualityTraceId() { + return ( + readerState.latestStep?.quality_trace_id || + readerState.latestStep?.chapter_view?.quality_trace_id || + readerState.latestStepFailure?.quality_trace_id || + null + ); +} + +async function submitReaderQualityFeedback(feedback) { + const traceId = currentReaderQualityTraceId(); + if (!traceId) { + reportUiMessage("当前章节还没有可关联的质量 trace,暂时不能提交反馈。", "warning"); + return; + } + try { + await api("/v1/reader/quality-feedback", { + method: "POST", + body: JSON.stringify({ + trace_id: traceId, + feedback, + reason_code: readerDom.readerQualityFeedbackReason?.value || null, + note: readerDom.readerQualityFeedbackNote?.value.trim() || null, + }), + }); + reportUiMessage("反馈已提交,运营面板中的 trace detail 会看到这条记录。", "success"); + } catch (error) { + reportUiMessage(`提交反馈失败:${error.message}`, "error"); + } +} + +async function renewReaderSubscription() { + const accountId = activeReaderId(); + try { + await api(`/v1/reader/subscription/${encodeURIComponent(accountId)}/renew`, { method: "POST" }); + await refreshReaderEntitlements(); + reportUiMessage("订阅续费请求已提交。", "success"); + } catch (error) { + reportUiMessage(`续费失败:${error.message}`, "error"); + } +} + +async function cancelReaderSubscription() { + const accountId = activeReaderId(); + try { + await api(`/v1/reader/subscription/${encodeURIComponent(accountId)}/cancel`, { method: "POST" }); + await refreshReaderEntitlements(); + reportUiMessage("订阅已标记为到期取消。", "success"); + } catch (error) { + reportUiMessage(`取消订阅失败:${error.message}`, "error"); + } +} + +async function grantReaderEntitlement() { + const readerId = activeReaderId(); + const entitlementType = readerDom.grantEntitlementType?.value || "credits"; + const payload = { + reader_id: readerId, + entitlement_type: entitlementType === "story_credits" ? "credits" : entitlementType, + }; + if (entitlementType === "story_credits") { + payload.wallet_type = "story_credits"; + payload.balance = Number(readerDom.grantEntitlementBalance?.value || 3); + } + if (entitlementType === "world_pass" && readerState.worldId) { + payload.world_id = readerState.worldId; + } + try { + await api("/v1/reader/entitlements/grant", { + method: "POST", + body: JSON.stringify(payload), + }); + await refreshReaderEntitlements(); + updateStatus(); + reportUiMessage("测试 entitlement 已授予。", "success"); + } catch (error) { + reportUiMessage(`授予测试 entitlement 失败:${error.message}`, "error"); + } +} + +function buildReaderPrologue() { + if (!readerState.currentBundle) return null; + const meta = worldDisplayMeta(readerState.currentBundle); + const suggested = readerState.currentBundle.player_inputs?.[0]?.raw_input || "我想先看看这条命会把我带去哪里。"; + return { + chapterTitle: `${readerState.currentBundle.label} · 序章`, + recap: `世界已经就位,但真正的章节还没落笔。先确认这条命的气味、代价和入口,再写下你的第一句心意。`, + body: + `${readerState.currentBundle.description || "这是一个还未真正展开的世界。"}\n\n` + + `此刻最重要的不是立刻做很多选择,而是先把第一步说清楚。这个世界更偏向 ${meta.mood},适合从“${suggested}”这样的心意起笔。`, + relationshipHints: [meta.hook, "先写一句心意,再看命运分岔。"], + chapterIndex: 0, + }; +} + +function recordReaderContinuityDiagnostic(key) { + if (!key) return; + const current = Number(readerState.continuityDiagnostics?.[key] || 0); + readerState.continuityDiagnostics = { + ...(readerState.continuityDiagnostics || {}), + [key]: current + 1, + }; +} + +function activeReaderQualityFailure() { + if (readerState.continuityContract?.status === "quality_guard_failed" && readerState.latestStepFailure) { + return readerState.latestStepFailure; + } + return null; +} + +function activeReaderPaywall() { + const latest = readerState.latestStep?.paywall || null; + if (latest?.required) return latest; + if (readerState.sessionPaywall?.required) return readerState.sessionPaywall; + return null; +} + +function buildReaderQualityGuardCard(stepFailure, options = {}) { + if (!stepFailure || stepFailure.status !== "quality_guard_failed") return null; + const card = document.createElement("section"); + const gate = stepFailure.quality_gate || {}; + const issues = (gate.issues || []).map((item) => item.issue_code).filter(Boolean); + const contract = stepFailure.continuity_contract || {}; + card.className = `chapter-unlock-card chapter-unlock-card--quality${options.variant ? ` chapter-unlock-card--${options.variant}` : ""}`; + card.innerHTML = ` +

这一章先没有入库

+

当前阅读位置已保留,可以直接重试当前章。

+

+ ${contract.message || gate.summary || "系统保留了当前 session、视图和上一章内容,方便你直接继续尝试。"} +

+
+ 当前文本:${Number(gate.actual_text_units || 0)}/${Number(gate.required_text_units || 0) || "-"} + 主要问题:${issues.join(" / ") || gate.enforced_decision || "rewrite"} + 推荐动作:重试当前章 +
+
+ + +
+ `; + card.querySelector(".chapter-quality-retry")?.addEventListener("click", async () => { + if (!readerDom.playerInput.value.trim()) { + readerDom.playerInput.value = readerState.intentPrefill?.suggested_prefill || "我想再试一次。"; + } + await stepSession(); + }); + card.querySelector(".chapter-quality-return")?.addEventListener("click", () => { + readerState.activeView = "storybook"; + syncViewMode(); + renderStorybook(); + }); + return card; +} + +function buildReaderUnlockCard(paywall, options = {}) { + if (!paywall?.required) return null; + const card = document.createElement("section"); + const tierText = paywall.required_display_name || (paywall.tier_id ? tierLabel(paywall.tier_id) : "继续阅读权益"); + const quoteText = paywall.quote !== undefined && paywall.quote !== null + ? `¥${Number(paywall.quote).toFixed(2)}` + : "按当前方案解锁"; + const balanceText = paywall.balance !== null && paywall.balance !== undefined + ? `${Number(paywall.balance).toFixed(0)} 故事点数` + : "暂无可用故事点数"; + const capabilityText = paywall.required_capability ? `需要 ${paywall.required_capability}` : "需要继续阅读权限"; + card.className = `chapter-unlock-card${options.variant ? ` chapter-unlock-card--${options.variant}` : ""}`; + card.innerHTML = ` +

这一章先停在这里

+

${accessReasonLabel(paywall.reason)},继续前需要先解锁。

+

+ ${capabilityText}。推荐通过 ${tierText} 继续,当前参考价格 ${quoteText}。 + 解锁后,你会直接回到这一章后面的那条命运线,而不是重新开始。 +

+
+ 当前世界:${worldUnlockLabel(paywall)} + 账户余额:${balanceText} + 推荐动作:解锁后继续阅读 +
+
+ +
+ `; + card.querySelector(".chapter-unlock-checkout")?.addEventListener("click", () => { + startReaderCheckout(paywall.suggested_checkout_tier || paywall.tier_id || "play_pass"); + }); + return card; +} + +function renderIntentPrefill() { + let prefilled = false; + if (!readerState.intentPrefill) { + readerDom.currentPressureText.textContent = "故事还没真正卷起来。"; + readerDom.lastIntentText.textContent = "-"; + readerDom.suggestedPrefillText.textContent = "我想先看看这条命会把我带去哪里。"; + if (typeof ShellStatusRuntime !== "undefined") { + ShellStatusRuntime.updateStatus(); + } + return; + } + readerDom.currentPressureText.textContent = readerState.intentPrefill.current_pressure || "上一章留下的余波还没散。"; + readerDom.lastIntentText.textContent = readerState.intentPrefill.last_player_intent || "-"; + readerDom.suggestedPrefillText.textContent = readerState.intentPrefill.suggested_prefill || ""; + if (!readerDom.playerInput.value.trim()) { + readerDom.playerInput.value = readerState.intentPrefill.suggested_prefill || ""; + prefilled = Boolean(readerDom.playerInput.value.trim()); + } + if (prefilled && typeof ShellStatusRuntime !== "undefined") { + ShellStatusRuntime.updateStatus(); + } +} + +function worldDisplayMeta(example) { + const localizedLabel = String(example.label || "") + .replace("Duty Route", "职责线") + .replace("Romance Route", "情感线"); + if (example.example_id === "romance") { + return { + label: localizedLabel, + mood: "爱 / 自我 / 迟疑", + hook: "更适合试探、坦白和关系拉扯。", + }; + } + return { + label: localizedLabel, + mood: "职责 / 名誉 / 自我", + hook: "更适合承诺、权衡和命运抉择。", + }; +} + +function firstImageUrl(...values) { + return values + .map((value) => String(value || "").trim()) + .find((value) => value.startsWith("/") || value.startsWith("http://") || value.startsWith("https://")) || ""; +} + +function escapeImageAttr(value) { + return String(value || "") + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function mergeReaderSessionMedia(payload = {}) { + const sessionPayload = payload.session || {}; + readerState.sessionMedia = { + coverImage: firstImageUrl( + payload.coverImage, + sessionPayload.coverImage, + readerState.sessionMedia?.coverImage + ), + atmosphereImage: firstImageUrl( + payload.atmosphereImage, + sessionPayload.atmosphereImage, + readerState.sessionMedia?.atmosphereImage + ), + }; +} + +function resetReaderSessionMedia() { + readerState.sessionMedia = { coverImage: "", atmosphereImage: "" }; +} + +function activeSessionSummary() { + return (readerState.sessionLibrary || []).find((item) => item.session_id === readerState.sessionId) || null; +} + +function activeReaderCoverImage() { + const activeSession = activeSessionSummary(); + return firstImageUrl( + readerState.sessionMedia?.coverImage, + activeSession?.coverImage, + readerState.latestStep?.coverImage + ); +} + +function activeReaderAtmosphereImage() { + return firstImageUrl( + readerState.sessionMedia?.atmosphereImage, + readerState.latestStep?.atmosphereImage, + activeSessionSummary()?.atmosphereImage, + activeReaderCoverImage() + ); +} + +function cardImageMarkup(imageUrl, altText) { + const safeUrl = firstImageUrl(imageUrl); + if (!safeUrl) return ""; + return ` +
+ ${escapeImageAttr(altText)} +
+ `; +} + +function cssImageUrl(imageUrl) { + return String(imageUrl || "").replace(/["\\\n\r]/g, ""); +} + +function setStoryHeroImage(imageUrl) { + if (!readerDom.storyHero) return; + const safeUrl = firstImageUrl(imageUrl); + readerDom.storyHero.classList.toggle("story-hero--with-image", Boolean(safeUrl)); + readerDom.storyHero.style.backgroundImage = safeUrl + ? `linear-gradient(180deg, rgba(18, 16, 14, 0.12), rgba(18, 16, 14, 0.72)), url("${cssImageUrl(safeUrl)}")` + : ""; +} + +function renderWorldGallery() { + clearNode(readerDom.worldGallery); + for (const example of readerState.examples) { + const meta = worldDisplayMeta(example); + const shelfWorld = readerState.shelfWorlds.find((item) => item.world_id === example.world_id); + const accessState = shelfWorld?.access_state || "trial"; + const riskRating = shelfWorld?.risk_rating || "PG-13"; + const coverImage = firstImageUrl(shelfWorld?.coverImage, example.coverImage); + const card = document.createElement("article"); + card.className = "world-card"; + card.dataset.exampleId = example.example_id; + if (readerState.currentBundle?.example_id === example.example_id) { + card.classList.add("is-selected"); + } + card.innerHTML = ` + ${cardImageMarkup(coverImage, meta.label || example.label)} +

${accessState === "trial" ? "可先试读" : "可直接进入"}

+

${meta.label || example.label}

+

${example.description}

+
+
+
主题气味
+
${meta.mood}
+
+
+
适合玩法
+
${meta.hook}
+
+
+
访问层级
+
${riskRating} / ${accessState}
+
+
+
+ 当前推荐入口 + ${readerState.currentBundle?.example_id === example.example_id ? "已选中" : "可切入"} +
+
+ + +
+ `; + card.querySelector(".world-card-preview").addEventListener("click", async () => { + await loadExampleBundle(example.example_id); + }); + card.querySelector(".world-card-start").addEventListener("click", async (event) => { + await loadExampleBundle(example.example_id); + await bootstrapWorld(event.currentTarget); + }); + readerDom.worldGallery.appendChild(card); + } +} + +function renderSessionLibrary() { + clearNode(readerDom.sessionLibrary); + if (!readerState.sessionLibrary.length) { + clearNode(readerDom.sessionLibrary, "你还没有在这个世界里留下脚印。开始一段新旅程吧。"); + return; + } + + for (const session of readerState.sessionLibrary) { + const card = document.createElement("article"); + card.className = "session-card"; + if (readerState.sessionId === session.session_id) { + card.classList.add("is-selected"); + } + const coverImage = firstImageUrl( + session.coverImage, + readerState.sessionId === session.session_id ? readerState.sessionMedia?.coverImage : "" + ); + card.innerHTML = ` + ${cardImageMarkup(coverImage, session.last_chapter_title || session.last_event_title || "旅程封面")} +

${readerState.sessionId === session.session_id ? "当前旅程" : "可安全续读"}

+

${session.last_chapter_title || session.last_event_title || "刚刚开始"}

+

+ 停在第 ${session.current_turn_index} 幕。${formatTimestamp(session.created_at)} 留下这段旅程,现在可以直接回到那一幕继续读。 +

+
+ 最近章节:${session.last_chapter_title || session.last_event_title || "等待第一幕"} + ${session.last_chapter_title || session.last_event_title ? "继续最安全" : "等待第一幕"} +
+
+ +
+
+ +
+ `; + card.querySelector(".session-card-open").addEventListener("click", async (event) => { + await restoreSession(session.session_id, event.currentTarget); + }); + card.querySelector(".session-card-delete").addEventListener("click", async () => { + await deleteSession(session.session_id); + }); + readerDom.sessionLibrary.appendChild(card); + } +} + +function renderAuthoredWorkLibrary() { + clearNode(readerDom.readerAuthoredWorkLibrary); + const authoredAccountId = activeAuthoredWorkAccountId(); + if (!authoredAccountId) { + clearNode(readerDom.readerAuthoredWorkLibrary, "登录作者账号后,这里会显示你自己创作的作品。"); + return; + } + if (!readerState.authoredWorkLibrary.length) { + clearNode(readerDom.readerAuthoredWorkLibrary, "你还没有生成可阅读的作品稿。先去创作台初始化作品稿并生成至少一章。"); + return; + } + + for (const work of readerState.authoredWorkLibrary) { + const card = document.createElement("article"); + card.className = "session-card"; + if (readerState.activeAuthoredWorkPreview?.work_id === work.work_id) { + card.classList.add("is-selected"); + } + const chapterCount = Number(work.chapter_count || 0); + const targetChapters = Number(work.target_chapter_count || 0); + card.innerHTML = ` + ${cardImageMarkup(work.coverImage, work.title || work.work_id || "作品封面")} +

${chapterCount > 0 ? "我的作品" : "等待正文"}

+

${work.title || work.work_id}

+

+ ${work.status || "draft"} · 章节 ${chapterCount}/${targetChapters || "-"}。 + ${chapterCount > 0 ? "可以直接按阅读视角查看已生成章节。" : "还没有可阅读正文,先去创作台生成章节。"} +

+
+ ${chapterCount > 0 ? "像读者一样阅读" : "先生成章节"} + ${work.world_version_id || work.work_id} +
+
+ +
+
+ + +
+ `; + card.querySelector(".authored-work-open")?.addEventListener("click", async () => { + await openAuthoredWorkPreview(work.work_id); + }); + card.querySelector(".authored-work-edit")?.addEventListener("click", async () => { + shellState.activeProduct = "author"; + authorState.activeDraftVersionId = work.world_version_id || authorState.activeDraftVersionId; + ShellStatusRuntime.syncProductMode(); + await AuthorWorkspaceRuntime.refreshAuthorSurface(); + }); + card.querySelector(".authored-work-delete")?.addEventListener("click", async () => { + await deleteAuthoredWork(work.work_id, work.title || work.work_id); + }); + readerDom.readerAuthoredWorkLibrary.appendChild(card); + } +} + +function renderSuggestedInputs() { + clearNode(readerDom.suggestedInputs); + if (!readerState.currentBundle) return; + for (const item of readerState.currentBundle.player_inputs) { + const fragment = readerDom.suggestionTemplate.content.cloneNode(true); + const button = fragment.querySelector("button"); + button.textContent = item.raw_input; + button.addEventListener("click", () => { + readerDom.playerInput.value = item.raw_input; + readerState.selectedIntentOverride = item.intent_vector || null; + updateStatus(); + }); + readerDom.suggestedInputs.appendChild(fragment); + } +} + +async function refreshAuthoredWorkLibrary() { + const authoredAccountId = activeAuthoredWorkAccountId(); + if (!authoredAccountId) { + readerState.authoredWorkLibrary = []; + renderAuthoredWorkLibrary(); + return; + } + try { + const payload = await api(`/v1/author/works?account_id=${encodeURIComponent(authoredAccountId)}`); + readerState.authoredWorkLibrary = payload.works || []; + } catch (_error) { + readerState.authoredWorkLibrary = []; + } + renderAuthoredWorkLibrary(); +} + +async function openAuthoredWorkPreview(workId) { + if (!workId) return; + const payload = await api(`/v1/author/works/${encodeURIComponent(workId)}`); + readerState.activeAuthoredWorkPreview = payload; + readerState.worldId = null; + readerState.sessionId = null; + readerState.currentState = null; + readerState.latestStep = null; + readerState.latestStepFailure = null; + readerState.continuityContract = null; + readerState.latestPreview = null; + readerState.replay = null; + readerState.selectedReplayIndex = null; + readerState.activeView = "experience"; + readerState.worldVersionId = payload.world_version_id || null; + setReaderWorkspace("read", { silent: true }); + renderAuthoredWorkLibrary(); + updateBundleSummary(); + updateStatus(); + renderLatestStep(); + renderReplay(); + renderRoutePreview(); + syncProductMode(); + readerDom.storyFeed?.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +async function deleteAuthoredWork(workId, title = "") { + if (!workId) return; + const workLabel = String(title || "这部作品").trim() || "这部作品"; + const confirmed = window.confirm(`删除后《${workLabel}》及其平行宇宙会一起移除,已生成章节也会被删除,确定继续吗?`); + if (!confirmed) return; + try { + const deleted = await api(`/v1/author/works/${encodeURIComponent(workId)}`, { method: "DELETE" }); + const deletedIds = new Set((deleted.deleted_work_ids || []).map((item) => String(item))); + if (readerState.activeAuthoredWorkPreview && deletedIds.has(String(readerState.activeAuthoredWorkPreview.work_id || ""))) { + clearAuthoredWorkPreview(); + readerState.worldVersionId = null; + readerState.sessionId = null; + readerState.currentState = null; + readerState.latestStep = null; + readerState.latestPreview = null; + readerState.replay = null; + resetReaderSessionMedia(); + readerState.selectedReplayIndex = null; + readerState.activeView = "experience"; + setReaderWorkspace("landing", { silent: true }); + renderRoutePreview(); + renderLatestStep(); + renderReplay(); + } + readerState.authoredWorkLibrary = readerState.authoredWorkLibrary.filter( + (item) => !deletedIds.has(String(item.work_id || "")) + ); + renderAuthoredWorkLibrary(); + await refreshAuthoredWorkLibrary(); + updateStatus(); + syncProductMode(); + reportUiMessage(`已删除《${workLabel}》及其 ${deleted.deleted_work_count || 1} 条命运线。`, "success"); + } catch (error) { + reportUiMessage(`删除作品失败:${error.message}`, "error"); + } +} + +function renderRoutePreview() { + if (!readerState.latestPreview?.routes?.length) { + clearNode(readerDom.routePreview, "还没有看到命运分岔。先开始一段旅程,再点“看看接下来”。"); + return; + } + clearNode(readerDom.routePreview); + const ranks = ["最有可能", "另一种走向", "隐秘支线"]; + const routes = readerState.latestPreview.routes.slice(0, 3); + routes.forEach((route, index) => { + const leadEvent = route.events?.[0]; + const line = document.createElement("div"); + line.className = "route-line"; + line.innerHTML = ` + ${ranks[index] || "可能的命运"} + ${leadEvent?.title || route.event_ids.join(" → ")} + 命运热度 ${route.total_score.toFixed(3)} +

${leadEvent?.summary || route.explanation || "这一条走向还没有更细的叙事说明。"}\n${index === 0 ? "最值得先看的,是它会把你立刻推向哪里。" : "这是一条备选走向,用来帮助你判断这一幕还能怎样偏转。"}

+ `; + readerDom.routePreview.appendChild(line); + }); + for (let index = routes.length; index < 3; index += 1) { + const line = document.createElement("div"); + line.className = "route-line route-line--placeholder"; + line.innerHTML = ` + ${ranks[index]} + 暂时没有更稳妥的备选走向 + 等待新的心意 +

等你把下一句心意写得更明确,系统才会展开更多足够可信的命运分岔。

+ `; + readerDom.routePreview.appendChild(line); + } +} + +function spotlightPreviewResult() { + if (!readerDom.routePreviewPanel) return; + readerDom.routePreviewPanel.classList.remove("is-highlighted"); + void readerDom.routePreviewPanel.offsetWidth; + readerDom.routePreviewPanel.classList.add("is-highlighted"); + readerDom.routePreviewPanel.scrollIntoView({ behavior: "smooth", block: "start" }); + window.setTimeout(() => { + readerDom.routePreviewPanel?.classList.remove("is-highlighted"); + }, 1400); +} + +function spotlightChapter() { + if (!readerDom.chapterPanel) return; + readerDom.chapterPanel.classList.remove("is-highlighted"); + void readerDom.chapterPanel.offsetWidth; + readerDom.chapterPanel.classList.add("is-highlighted"); + readerDom.chapterPanel.scrollIntoView({ behavior: "smooth", block: "start" }); + window.setTimeout(() => { + readerDom.chapterPanel?.classList.remove("is-highlighted"); + }, 1400); +} + +function renderCards(target, items, formatter, emptyText) { + clearNode(target); + if (!items.length) { + clearNode(target, emptyText); + return; + } + target.classList.remove("empty-state"); + for (const item of items) { + const fragment = readerDom.listCardTemplate.content.cloneNode(true); + const title = fragment.querySelector("h3"); + const score = fragment.querySelector(".list-card-score"); + const body = fragment.querySelector(".list-card-body"); + const formatted = formatter(item); + title.textContent = formatted.title; + score.textContent = formatted.score; + body.textContent = formatted.body; + if (formatted.active) { + fragment.querySelector(".list-card").classList.add("is-active"); + } + target.appendChild(fragment); + } +} + +function setTone(tone) { + readerState.activeTone = tone; + for (const pill of readerDom.tonePills) { + pill.classList.toggle("is-active", pill.dataset.tone === tone); + } + renderStorybook(); + renderStoryFeed(); +} + +function getStorySource() { + if (readerState.selectedReplayIndex !== null && readerState.replay?.event_trace?.[readerState.selectedReplayIndex]) { + return { + event: readerState.replay.event_trace[readerState.selectedReplayIndex], + rendered: readerState.replay.rendered_scenes?.[readerState.selectedReplayIndex] || null, + reader_view: readerState.replay.reader_views?.[readerState.selectedReplayIndex] || null, + index: readerState.selectedReplayIndex, + }; + } + if (readerState.latestStep?.chosen_event) { + return { + event: readerState.latestStep.chosen_event, + rendered: readerState.latestStep.rendered_scene, + reader_view: readerState.latestStep.reader_view || null, + index: null, + }; + } + return null; +} + +function renderStorybook() { + const source = getStorySource(); + const prologue = !source && readerState.sessionId ? buildReaderPrologue() : null; + const activePaywall = activeReaderPaywall(); + const activeQualityFailure = activeReaderQualityFailure(); + if (!source && !prologue) { + setStoryHeroImage(""); + readerDom.storyHero.dataset.motif = ""; + readerDom.storyTitle.textContent = "画面会在这里展开"; + readerDom.storyCaption.textContent = "推进一幕之后,这里会变成一张带情绪和光影的故事画面。"; + if (readerDom.storyRecap) { + readerDom.storyRecap.textContent = "当你进入章节后,这里会先把上一章到这一章之间真正重要的变化说清楚。"; + } + readerDom.storyQuote.textContent = "当故事开始流动,这里会出现一句最能代表这一幕的引句。"; + readerDom.storyPrompt.textContent = "-"; + readerDom.storyMotif.textContent = "-"; + clearNode(readerDom.storyBeats, "这里会显示这一幕最值得抓住的三个节拍。"); + clearNode(readerDom.storyDetails, "这里会显示画面中的气味、动作和情绪提示。"); + readerDom.storyProse.textContent = "这里会显示图文版本对应的正文。"; + clearNode(readerDom.storySequence, "故事累积起来后,这里会变成一条可以回看的章节画卷。"); + return; + } + + if (prologue) { + const meta = worldDisplayMeta(readerState.currentBundle); + setStoryHeroImage(activeReaderCoverImage()); + readerDom.storyHero.dataset.motif = readerState.currentBundle.example_id === "romance" ? "temptation" : "discovery"; + readerDom.storyTitle.textContent = prologue.chapterTitle; + readerDom.storyCaption.textContent = readerState.currentBundle.description || "这是旅程真正开写之前的入口页。"; + if (readerDom.storyRecap) { + readerDom.storyRecap.textContent = prologue.recap; + } + readerDom.storyQuote.textContent = readerState.currentBundle.player_inputs?.[0]?.raw_input || "先写下你真正想做的第一件事。"; + readerDom.storyPrompt.textContent = meta.hook; + readerDom.storyMotif.textContent = meta.mood; + clearNode(readerDom.storyBeats); + ["先判断是否继续旧旅程", "再决定从哪个世界起笔", "最后写下第一句心意"].forEach((beat) => { + const node = document.createElement("span"); + node.className = "story-beat"; + node.textContent = beat; + readerDom.storyBeats.appendChild(node); + }); + clearNode(readerDom.storyDetails); + [meta.mood, "序章态", "等待第一幕"].forEach((detail) => { + const node = document.createElement("span"); + node.className = "story-detail"; + node.textContent = detail; + readerDom.storyDetails.appendChild(node); + }); + readerDom.storyProse.textContent = prologue.body; + readerDom.storyProse.parentElement?.querySelector(".chapter-unlock-card--storybook")?.remove(); + if (activePaywall) { + const card = buildReaderUnlockCard(activePaywall, { variant: "storybook" }); + if (card) { + readerDom.storyProse.parentElement?.appendChild(card); + } + } else if (activeQualityFailure) { + const card = buildReaderQualityGuardCard(activeQualityFailure, { variant: "storybook" }); + if (card) { + readerDom.storyProse.parentElement?.appendChild(card); + } + } + clearNode(readerDom.storySequence, "真正推进之后,这里会变成一条按章节回看的时间线。"); + return; + } + + const rendered = source.rendered || {}; + const readerView = source.reader_view || {}; + const sceneCard = readerView.scene_card || {}; + setStoryHeroImage(activeReaderAtmosphereImage()); + readerDom.storyHero.dataset.motif = rendered.image_motif || source.event.scene_function || ""; + readerDom.storyTitle.textContent = readerView.chapter_title || rendered.story_title || source.event.title || "当前剧情"; + readerDom.storyCaption.textContent = readerView.recap || rendered.chapter_summary || rendered.image_caption || source.event.summary || "暂无说明。"; + if (readerDom.storyRecap) { + readerDom.storyRecap.textContent = readerView.recap || rendered.chapter_summary || source.event.summary || "这一章还没有可用的 recap。"; + } + readerDom.storyQuote.textContent = sceneCard.quote || rendered.pull_quote || "这一幕还没有留下自己的引句。"; + readerDom.storyPrompt.textContent = sceneCard.summary || rendered.visual_prompt || "暂无 visual prompt"; + readerDom.storyMotif.textContent = sceneCard.palette_hint || rendered.image_motif || source.event.scene_function || "-"; + clearNode(readerDom.storyBeats); + const beatItems = sceneCard.story_beats || rendered.story_beats || []; + if (beatItems.length) { + beatItems.forEach((beat) => { + const node = document.createElement("span"); + node.className = "story-beat"; + node.textContent = beat; + readerDom.storyBeats.appendChild(node); + }); + } else { + clearNode(readerDom.storyBeats, "这里会显示这一幕最值得抓住的三个节拍。"); + } + clearNode(readerDom.storyDetails); + const detailItems = sceneCard.visual_details || rendered.visual_details || []; + if (detailItems.length) { + detailItems.forEach((detail) => { + const node = document.createElement("span"); + node.className = "story-detail"; + node.textContent = detail; + readerDom.storyDetails.appendChild(node); + }); + } else { + clearNode(readerDom.storyDetails, "这里会显示画面中的气味、动作和情绪提示。"); + } + readerDom.storyProse.textContent = + readerView.body || + rendered[readerState.activeTone] || + rendered.premium_prose || + source.event.summary || + "暂无正文。"; + readerDom.storyProse.parentElement?.querySelector(".chapter-unlock-card--storybook")?.remove(); + if (activePaywall) { + const card = buildReaderUnlockCard(activePaywall, { variant: "storybook" }); + if (card) { + readerDom.storyProse.parentElement?.appendChild(card); + } + } else if (activeQualityFailure) { + const card = buildReaderQualityGuardCard(activeQualityFailure, { variant: "storybook" }); + if (card) { + readerDom.storyProse.parentElement?.appendChild(card); + } + } + + clearNode(readerDom.storySequence); + if (!readerState.replay?.event_trace?.length) { + clearNode(readerDom.storySequence, "故事累积起来后,这里会变成一条可以回看的章节画卷。"); + return; + } + + readerState.replay.event_trace.forEach((event, index) => { + const renderedScene = readerState.replay.rendered_scenes?.[index] || {}; + const readerView = readerState.replay.reader_views?.[index] || {}; + const card = document.createElement("article"); + card.className = "story-sequence-card"; + if (index === readerState.selectedReplayIndex) { + card.classList.add("is-active"); + } + card.innerHTML = ` +

Turn ${index + 1} · ${readerView.chapter_title || renderedScene.story_title || event.title}

+

${readerView.recap || renderedScene.chapter_summary || renderedScene.image_caption || event.summary}

+ `; + card.addEventListener("click", () => { + readerState.selectedReplayIndex = index; + renderReplay(); + renderStorybook(); + }); + readerDom.storySequence.appendChild(card); + }); +} + +function renderLatestStep() { + if (readerState.activeAuthoredWorkPreview) { + const activeWork = readerState.activeAuthoredWorkPreview; + const chapters = activeWork.chapters || []; + const latestChapter = chapters[chapters.length - 1] || null; + readerDom.chosenEventTitle.textContent = activeWork.title || "我的作品"; + readerDom.bestRoute.textContent = chapters.length + ? `这是你的作品只读预览,当前已生成 ${chapters.length} 章。你可以像读者一样顺序查看,再回创作台继续修改。` + : "这份作品稿还没有可阅读章节。"; + clearNode(readerDom.scoredCandidates, "作者作品只读预览不提供路线比较。"); + clearNode(readerDom.criticTrace, "作者作品只读预览不显示候选诊断轨迹。"); + readerDom.lastEventTitle.textContent = latestChapter?.chapter_title || "-"; + readerDom.paywallBanner.classList.add("is-hidden"); + renderStorybook(); + renderStoryFeed(); + renderIntentPrefill(); + return; + } + if (!readerState.latestStep) { + const prologue = readerState.sessionId ? buildReaderPrologue() : null; + const qualityFailure = activeReaderQualityFailure(); + readerDom.chosenEventTitle.textContent = prologue?.chapterTitle || "故事还没开始"; + readerDom.bestRoute.textContent = qualityFailure + ? (readerState.continuityContract?.message || "本章未入库,但当前 session 和上一章内容都还保留着,可以直接重试。") + : prologue + ? "旅程已经启动,但第一章还等你写下第一句心意。先判断这条命往哪边偏,再决定要不要真的踏出去。" + : "当你写下一句心意,系统会在这里接住它。"; + clearNode(readerDom.storyFeed, "载入 world 并执行一步后,这里会按时间顺序出现连续章节。"); + clearNode(readerDom.scoredCandidates, "幕后会在这里比较不同走向。"); + clearNode(readerDom.criticTrace, "幕后会在这里解释为什么这条线更成立。"); + readerDom.lastEventTitle.textContent = prologue?.chapterTitle || "-"; + readerDom.paywallBanner.classList.add("is-hidden"); + renderStorybook(); + renderStoryFeed(); + renderIntentPrefill(); + return; + } + + const readerView = readerState.latestStep.reader_view || {}; + readerDom.chosenEventTitle.textContent = readerView.chapter_title || readerState.latestStep.chosen_event.title; + readerDom.lastEventTitle.textContent = readerView.chapter_title || readerState.latestStep.chosen_event.title; + readerDom.bestRoute.textContent = readerState.latestStep.routes?.length + ? (() => { + const routeEvents = readerState.latestStep.routes[0].events || []; + const titles = routeEvents.map((event) => event.title).filter(Boolean); + if (!titles.length) return "主线已经开始往下一处更难退开的命运口子靠近。"; + if (titles.length === 1) return `接下来更可能逼近的是:${titles[0]}。`; + return `接下来更可能先逼近“${titles[0]}”,随后余波会把你带向“${titles[1]}”。`; + })() + : readerView.recap || "此刻还没有新的主线判断。"; + + const batch = readerState.latestStep.candidate_batch || { raw_candidates: [], legal_candidates: [], debug: {} }; + readerDom.candidateSummary.textContent = + batch.raw_candidates?.length + ? `系统刚才比对了 ${batch.raw_candidates.length} 种可能,留下 ${batch.legal_candidates.length} 条真正说得通的走向。` + : "幕后会在这里比较不同走向。"; + + renderCards( + readerDom.scoredCandidates, + readerState.latestStep.scored_candidates || [], + (item) => ({ + title: item.event.title, + score: `匹配度 ${item.total_score.toFixed(3)}`, + body: + `${item.explanation}\n` + + (item.critic_decisions?.length + ? item.critic_decisions + .map((decision) => `${decision.critic_name}: ${decision.verdict} · ${decision.reasons.join(" / ")}`) + .join("\n") + : "这一条线没有额外诊断备注。"), + }), + "幕后会在这里比较不同走向。" + ); + + renderCards( + readerDom.criticTrace, + readerState.latestStep.critic_trace || [], + (item) => ({ + title: item.event_id, + score: `修正 ${Number(item.critic_penalty || 0).toFixed(3)}`, + body: + (item.critic_decisions || []) + .map((decision) => `${decision.critic_name}: ${decision.verdict} · ${decision.reasons.join(" / ")}`) + .join("\n") || "这一步没有额外诊断。", + }), + "幕后会在这里解释为什么这条线更成立。" + ); + + readerDom.paywallBanner.classList.add("is-hidden"); + if (readerDom.paywallBannerCheckout) { + readerDom.paywallBannerCheckout.onclick = null; + } + for (const pill of readerDom.tonePills) { + pill.classList.toggle("is-active", pill.dataset.tone === readerState.activeTone); + } + renderStorybook(); + renderStoryFeed(); + renderIntentPrefill(); +} + +function renderStoryFeed() { + const activePaywall = activeReaderPaywall(); + const activeQualityFailure = activeReaderQualityFailure(); + const chapters = []; + if (readerState.activeAuthoredWorkPreview?.chapters?.length) { + readerState.activeAuthoredWorkPreview.chapters.forEach((chapter) => { + const issueCodes = (chapter.latest_diagnostic_summary || {}).issue_codes || []; + const chapterTask = chapter.chapter_task || {}; + chapters.push({ + chapterTitle: chapter.chapter_title, + recap: chapter.summary, + body: chapter.body, + relationshipHints: [ + chapterTask.duty_type ? `任务 ${chapterTask.duty_type}` : "", + ...issueCodes.slice(0, 2), + ].filter(Boolean), + chapterIndex: chapter.chapter_index || 0, + }); + }); + } else if (readerState.replay?.reader_views?.length) { + readerState.replay.reader_views.forEach((readerView, index) => { + chapters.push({ + chapterTitle: readerView.chapter_title, + recap: readerView.recap, + body: readerView.body, + relationshipHints: readerView.relationship_hints || [], + chapterIndex: readerView.chapter_index || index + 1, + }); + }); + } else if (readerState.latestStep?.reader_view) { + chapters.push({ + chapterTitle: readerState.latestStep.reader_view.chapter_title, + recap: readerState.latestStep.reader_view.recap, + body: readerState.latestStep.reader_view.body, + relationshipHints: readerState.latestStep.reader_view.relationship_hints || [], + chapterIndex: readerState.latestStep.reader_view.chapter_index || 1, + }); + } else if (readerState.sessionId) { + const prologue = buildReaderPrologue(); + if (prologue) { + chapters.push(prologue); + } + } + + clearNode(readerDom.storyFeed); + if (!chapters.length) { + clearNode(readerDom.storyFeed, "载入 world 并执行一步后,这里会按时间顺序出现连续章节。"); + return; + } + + chapters.forEach((chapter, index) => { + const card = document.createElement("article"); + card.className = "story-feed-card"; + if (index === chapters.length - 1) { + card.classList.add("is-active"); + } + const chapterLabel = Number(chapter.chapterIndex || 0) > 0 ? `第 ${chapter.chapterIndex} 章` : "序章"; + card.innerHTML = ` +
+

${chapterLabel}

+

${chapter.chapterTitle}

+
+

${chapter.recap || ""}

+
${chapter.body || ""}
+ ${chapter.relationshipHints.length ? `
${chapter.relationshipHints.map((hint) => `${hint}`).join("")}
` : ""} + `; + if (index === chapters.length - 1 && activePaywall) { + const unlockCard = buildReaderUnlockCard(activePaywall, { variant: "feed" }); + if (unlockCard) { + card.appendChild(unlockCard); + } + } else if (index === chapters.length - 1 && activeQualityFailure) { + const qualityCard = buildReaderQualityGuardCard(activeQualityFailure, { variant: "feed" }); + if (qualityCard) { + card.appendChild(qualityCard); + } + } + readerDom.storyFeed.appendChild(card); + }); +} + +function renderReplay() { + if (!readerState.replay?.event_trace?.length) { + clearNode(readerDom.replayTimeline, "推进几幕之后,这里会变成一条可回看的章节轨迹。"); + renderStorybook(); + return; + } + renderCards( + readerDom.replayTimeline, + readerState.replay.event_trace.map((event, index) => ({ + event, + index, + promises: readerState.replay.promise_ledger_snapshots[index] || [], + readerView: readerState.replay.reader_views?.[index] || {}, + })), + ({ event, index, promises, readerView }) => ({ + title: `Turn ${index + 1} · ${readerView.chapter_title || event.title}`, + score: event.scene_function || "", + body: + `${readerView.recap || event.summary}\n` + + `未解牵挂: ${promises.length}\n` + + `Tags: ${(event.tags || []).join(", ")}`, + active: index === readerState.selectedReplayIndex, + }), + "推进几幕之后,这里会变成一条可回看的章节轨迹。" + ); + renderStorybook(); +} + +function updateBundleSummary() { + if (readerState.activeAuthoredWorkPreview) { + const activeWork = readerState.activeAuthoredWorkPreview; + const chapterCount = Number(activeWork.chapter_count || (activeWork.chapters || []).length || 0); + const targetCount = Number(activeWork.target_chapter_count || 0); + readerDom.worldTitle.textContent = `我的作品 · ${activeWork.title || activeWork.work_id || "未命名作品"}`; + readerDom.worldDescription.textContent = "这里展示的是你自己创作出的章节正文,可按读者视角连续阅读,不会推进读者会话。"; + readerDom.featuredWorldTitle.textContent = activeWork.title || "我的作品"; + readerDom.featuredWorldCopy.textContent = "把已生成章节摊开读一遍,检查情绪、节奏和关系推进,再回创作台继续修改。"; + readerDom.featuredWorldMood.textContent = `${chapterCount}/${targetCount || "-"} 章`; + readerDom.featuredWorldHook.textContent = activeWork.status || "draft"; + if (readerDom.readerFeaturedStart) { + readerDom.readerFeaturedStart.disabled = true; + } + return; + } + if (!readerState.currentBundle) { + readerDom.worldTitle.textContent = "选择一个世界"; + readerDom.worldDescription.textContent = "先挑一个世界,再开始一段新的命运旅程。"; + readerDom.featuredWorldTitle.textContent = "先挑一个世界,再开始一段新的命运旅程。"; + readerDom.featuredWorldCopy.textContent = "你会在这里看到这个世界的主命题、情绪底色,以及这一轮旅程最适合怎样推进。"; + readerDom.featuredWorldMood.textContent = "-"; + readerDom.featuredWorldHook.textContent = "-"; + if (readerDom.readerFeaturedStart) { + readerDom.readerFeaturedStart.disabled = true; + } + return; + } + const meta = worldDisplayMeta(readerState.currentBundle); + readerDom.worldTitle.textContent = meta.label || readerState.currentBundle.label; + readerDom.worldDescription.textContent = readerState.currentBundle.description; + readerDom.featuredWorldTitle.textContent = meta.label || readerState.currentBundle.label; + readerDom.featuredWorldCopy.textContent = readerState.currentBundle.description; + readerDom.featuredWorldMood.textContent = meta.mood; + readerDom.featuredWorldHook.textContent = meta.hook; + if (readerDom.readerFeaturedStart) { + readerDom.readerFeaturedStart.disabled = false; + } +} + +async function loadExampleBundle(exampleId) { + readerState.currentBundle = await api(`/v1/examples/${exampleId}`); + const localizedMeta = worldDisplayMeta(readerState.currentBundle); + if (localizedMeta.label) { + readerState.currentBundle.label = localizedMeta.label; + } + readerState.worldId = readerState.currentBundle.world_bible.world_id; + readerState.selectedIntentOverride = null; + if (!readerState.sessionId) { + setReaderWorkspace("landing", { silent: true }); + } + updateBundleSummary(); + renderWorldGallery(); + renderSuggestedInputs(); + await refreshSessionLibrary(); + await refreshReaderEntitlements(); + updateStatus(); +} + +async function refreshExamples() { + const payload = await api("/v1/examples"); + readerState.examples = payload.examples; + const shelfPayload = await api("/v1/library/worlds"); + readerState.shelfWorlds = shelfPayload.worlds; + const selected = readerState.examples.find((item) => item.example_id === "demo") || readerState.examples[0]; + if (selected) { + await loadExampleBundle(selected.example_id); + } + await refreshAuthoredWorkLibrary(); + syncProductMode(); +} + +async function refreshSessionLibrary() { + if (readerState.activeAuthoredWorkPreview) { + renderSessionLibrary(); + return; + } + if (!readerState.currentBundle) { + readerState.sessionLibrary = []; + renderSessionLibrary(); + return; + } + if (!readerState.readerAuthSession?.accessToken && !(readerState.readerAuthSession?.cookieBacked && readerState.readerAuthSession?.identity)) { + readerState.sessionLibrary = []; + renderSessionLibrary(); + return; + } + const payload = await api(`/v1/sessions?world_id=${encodeURIComponent(readerState.currentBundle.world_bible.world_id)}`); + readerState.sessionLibrary = payload.sessions; + const activeSession = activeSessionSummary(); + if (activeSession) { + mergeReaderSessionMedia(activeSession); + } + renderSessionLibrary(); +} + +async function bootstrapWorld(triggerButton = null) { + if (!readerState.currentBundle) return; + const restore = triggerButton ? setBusy(triggerButton, "进入中…") : () => {}; + try { + readerState.activeAuthoredWorkPreview = null; + const worldPayload = { + world_bible: readerState.currentBundle.world_bible, + event_atoms: readerState.currentBundle.event_atoms, + metadata: { source: "frontend_bootstrap" }, + }; + const worldResult = await api("/v1/worlds", { + method: "POST", + body: JSON.stringify(worldPayload), + }); + const sessionResult = await api("/v1/sessions", { + method: "POST", + body: JSON.stringify({ + world_id: worldResult.world_id, + initial_state: readerState.currentBundle.initial_state, + player_profile: { surface: "app", reader_id: activeReaderId() }, + metadata: { reader_id: activeReaderId() }, + }), + }); + + readerState.worldId = worldResult.world_id; + readerState.worldVersionId = sessionResult.world_version_id || null; + readerState.sessionPaywall = sessionResult.paywall || null; + readerState.sessionId = sessionResult.session_id; + resetReaderSessionMedia(); + mergeReaderSessionMedia(sessionResult); + readerState.currentState = sessionResult.current_state; + readerState.intentPrefill = { + last_player_intent: "", + current_pressure: "故事刚刚开始。", + suggested_prefill: "我想先试探眼前这条路到底会把我带到哪一边。", + }; + readerState.latestStep = null; + readerState.latestStepFailure = null; + readerState.continuityContract = null; + readerState.latestPreview = null; + readerState.replay = null; + readerState.selectedReplayIndex = null; + setReaderWorkspace("read", { silent: true }); + + await refreshSessionLibrary(); + await refreshReaderEntitlements(); + updateStatus(); + renderRoutePreview(); + renderLatestStep(); + renderReplay(); + syncProductMode(); + reportUiMessage("旅程已经开始,接下来可以先阅读当前章,再写下一句心意。", "success"); + } catch (error) { + reportUiMessage(`开始旅程失败:${error.message}`, "error"); + } finally { + restore(); + } +} + +async function restoreSession(sessionId, triggerButton = null) { + if (!sessionId) return; + const restore = triggerButton ? setBusy(triggerButton, "回到这一幕…") : () => {}; + try { + readerState.activeAuthoredWorkPreview = null; + const sessionPayload = await api(`/v1/sessions/${sessionId}`); + const replayPayload = await api(`/v1/sessions/${sessionId}/replay`); + const matchingExample = readerState.examples.find((item) => item.world_id === sessionPayload.session.world_id); + if (matchingExample && readerState.currentBundle?.example_id !== matchingExample.example_id) { + readerState.currentBundle = await api(`/v1/examples/${matchingExample.example_id}`); + updateBundleSummary(); + renderWorldGallery(); + renderSuggestedInputs(); + } + readerState.sessionId = sessionId; + resetReaderSessionMedia(); + mergeReaderSessionMedia(sessionPayload); + readerState.currentState = sessionPayload.session.current_state; + readerState.sessionPaywall = sessionPayload.paywall || null; + readerState.latestStep = sessionPayload.latest_step; + readerState.latestStepFailure = null; + readerState.continuityContract = null; + readerState.replay = replayPayload; + readerState.worldId = sessionPayload.session.world_id; + readerState.worldVersionId = sessionPayload.world_version_id || sessionPayload.session.metadata?.world_version_id || null; + readerState.readerId = sessionPayload.session.metadata?.reader_id || readerState.readerId; + readerState.intentPrefill = sessionPayload.intent_prefill || (await api(`/v1/sessions/${sessionId}/prefill`)); + readerState.selectedReplayIndex = replayPayload.event_trace.length + ? replayPayload.event_trace.length - 1 + : null; + readerState.activeView = "experience"; + setReaderWorkspace("read", { silent: true }); + shellState.pendingSessionId = null; + recordReaderContinuityDiagnostic("restore_success_count"); + renderSessionLibrary(); + await refreshReaderEntitlements(); + updateStatus(); + renderLatestStep(); + renderReplay(); + syncProductMode(); + spotlightChapter(); + } catch (error) { + reportUiMessage(`继续旅程失败:${error.message}`, "error"); + } finally { + restore(); + } +} + +async function deleteSession(sessionId) { + if (!sessionId) return; + const confirmed = window.confirm("删除后这段旅程会从书架中移除,确定继续吗?"); + if (!confirmed) return; + try { + await api(`/v1/sessions/${sessionId}`, { method: "DELETE" }); + if (readerState.sessionId === sessionId) { + readerState.sessionId = null; + readerState.currentState = null; + readerState.latestStep = null; + readerState.latestStepFailure = null; + readerState.continuityContract = null; + readerState.latestPreview = null; + readerState.replay = null; + resetReaderSessionMedia(); + readerState.selectedReplayIndex = null; + readerState.activeView = "experience"; + setReaderWorkspace("landing", { silent: true }); + renderRoutePreview(); + renderLatestStep(); + renderReplay(); + } + await refreshSessionLibrary(); + updateStatus(); + syncProductMode(); + } catch (error) { + reportUiMessage(`删除失败:${error.message}`, "error"); + } +} + +async function previewRoute() { + if (!readerState.currentBundle || !readerState.currentState) return; + const restore = setBusy(readerDom.previewRoute, "预览中…"); + try { + const previewState = + typeof structuredClone === "function" + ? structuredClone(readerState.currentState) + : JSON.parse(JSON.stringify(readerState.currentState)); + if (readerState.selectedIntentOverride) { + previewState.player_intent = readerState.selectedIntentOverride; + } + readerState.latestPreview = await api("/v1/routes/preview", { + method: "POST", + body: JSON.stringify({ + world: readerState.currentBundle.world_bible, + state: previewState, + candidate_events: readerState.currentBundle.event_atoms, + beam_width: 3, + depth: 2, + }), + }); + renderRoutePreview(); + spotlightPreviewResult(); + } catch (error) { + reportUiMessage(`没能看到下一步:${error.message}`, "error"); + } finally { + restore(); + } +} + +async function stepSession() { + if (!readerState.sessionId) return; + const playerInput = readerDom.playerInput.value.trim(); + if (!playerInput) { + reportUiMessage("先写下一句你现在真正想做的事。", "warning"); + return; + } + const restore = setBusy(readerDom.stepSession, "执行中…"); + try { + const previousContinuityStatus = String(readerState.continuityContract?.status || ""); + const stepPath = `/v1/sessions/${readerState.sessionId}/step${shellState.debug ? "?debug=true" : ""}`; + const stepResult = await api(stepPath, { + method: "POST", + body: JSON.stringify({ + player_input: playerInput, + intent_override: readerState.selectedIntentOverride, + beam_width: 3, + depth: 2, + metadata: { reader_id: activeReaderId() }, + }), + }); + mergeReaderSessionMedia(stepResult); + readerState.continuityContract = stepResult.continuity_contract || null; + if (stepResult.code === "chapter_quality_guard_failed" || stepResult.status === "quality_guard_failed") { + readerState.latestStepFailure = stepResult; + readerState.sessionPaywall = stepResult.paywall || readerState.sessionPaywall; + setReaderWorkspace("read", { silent: true }); + recordReaderContinuityDiagnostic("quality_guard_context_retained_count"); + if (previousContinuityStatus === "payment_required") { + recordReaderContinuityDiagnostic("post_checkout_resume_success_count"); + } + await refreshReaderEntitlements(); + updateStatus(); + renderLatestStep(); + renderReplay(); + renderStorybook(); + renderStoryFeed(); + reportUiMessage( + stepResult.continuity_contract?.message || + `本章未入库:${Number((stepResult.quality_gate || {}).actual_text_units || 0)}/${Number((stepResult.quality_gate || {}).required_text_units || 0) || "-"} 字。`, + "warning" + ); + return; + } + if (stepResult.status && stepResult.status !== "ok") { + readerState.latestStepFailure = null; + readerState.sessionPaywall = stepResult.paywall || readerState.sessionPaywall; + setReaderWorkspace("read", { silent: true }); + if (stepResult.status === "payment_required") { + recordReaderContinuityDiagnostic("paywall_resume_ready_count"); + } + await refreshReaderEntitlements(); + updateStatus(); + renderLatestStep(); + renderReplay(); + reportUiMessage( + stepResult.continuity_contract?.message || `继续前需要先解锁:${accessReasonLabel(stepResult.paywall?.reason)}。`, + "warning" + ); + return; + } + readerState.latestStep = stepResult; + readerState.latestStepFailure = null; + readerState.currentState = readerState.latestStep.updated_state; + readerState.worldVersionId = readerState.latestStep.world_version_id || readerState.worldVersionId; + readerState.sessionPaywall = readerState.latestStep.paywall || readerState.sessionPaywall; + if (previousContinuityStatus === "payment_required") { + recordReaderContinuityDiagnostic("post_checkout_resume_success_count"); + } + readerState.replay = await api(`/v1/sessions/${readerState.sessionId}/replay`); + readerState.intentPrefill = await api(`/v1/sessions/${readerState.sessionId}/prefill`); + readerState.selectedReplayIndex = readerState.replay.event_trace.length + ? readerState.replay.event_trace.length - 1 + : null; + await refreshSessionLibrary(); + await refreshReaderEntitlements(); + updateStatus(); + renderLatestStep(); + renderReplay(); + reportUiMessage("这一幕已经推进,新的章节和回放都已更新。", "success"); + } catch (error) { + reportUiMessage(`这一幕没能推进:${error.message}`, "error"); + } finally { + restore(); + } +} + +function resetOutput() { + readerState.latestStep = null; + readerState.latestStepFailure = null; + readerState.continuityContract = null; + readerState.latestPreview = null; + readerState.replay = null; + readerState.intentPrefill = null; + readerState.selectedReplayIndex = null; + readerDom.playerInput.value = ""; + renderRoutePreview(); + renderLatestStep(); + renderReplay(); + clearStatusBanner(); +} + +async function bootstrapHealth() { + try { + const payload = await api("/health", { headers: {} }); + shellDom.apiStatus.textContent = payload.status === "ok" ? "在线" : "异常"; + } catch (error) { + shellDom.apiStatus.textContent = "离线"; + } +} +let readerEventsBound = false; +let readerRuntimeInitialized = false; + +function bindReaderEvents() { + if (readerEventsBound) return; + readerEventsBound = true; + readerDom.previewRoute?.addEventListener("click", previewRoute); + readerDom.stepSession?.addEventListener("click", stepSession); + readerDom.resetOutput?.addEventListener("click", resetOutput); + readerDom.playerInput?.addEventListener("input", () => { + readerState.selectedIntentOverride = null; + updateStatus(); + }); + readerDom.viewExperience?.addEventListener("click", () => { + readerState.activeView = "experience"; + syncViewMode(); + }); + readerDom.viewStorybook?.addEventListener("click", () => { + readerState.activeView = "storybook"; + syncViewMode(); + renderStorybook(); + }); + readerDom.viewBackstage?.addEventListener("click", () => { + readerState.activeView = "backstage"; + syncViewMode(); + }); + readerDom.readerJumpSessions?.addEventListener("click", () => { + readerDom.sessionLibrary?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + readerDom.readerJumpWorlds?.addEventListener("click", () => { + readerDom.worldGallery?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + readerDom.readerJumpAuthoredWorks?.addEventListener("click", () => { + readerDom.readerAuthoredWorkLibrary?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + readerDom.readerStartCurrentWorld?.addEventListener("click", async (event) => { + try { + if (!readerState.currentBundle) { + await refreshExamples(); + } + await bootstrapWorld(event.currentTarget); + } catch (error) { + reportUiMessage(`开始新旅程失败:${error.message}`, "error"); + } + }); + readerDom.readerFeaturedStart?.addEventListener("click", async (event) => { + try { + await bootstrapWorld(event.currentTarget); + } catch (error) { + reportUiMessage(`开始新旅程失败:${error.message}`, "error"); + } + }); + readerDom.readerReturnLanding?.addEventListener("click", () => { + clearAuthoredWorkPreview(); + setReaderWorkspace("landing"); + updateBundleSummary(); + renderSessionLibrary(); + renderAuthoredWorkLibrary(); + renderLatestStep(); + }); + readerDom.readerBackstageClose?.addEventListener("click", () => { + readerState.activeView = shellState.lastReaderView || "experience"; + syncViewMode(); + }); + readerDom.readerRefreshEntitlements?.addEventListener("click", refreshReaderEntitlements); + readerDom.readerAuthRegister?.addEventListener("click", registerReaderAuthIdentity); + readerDom.readerAuthLogin?.addEventListener("click", loginReaderAuthIdentity); + readerDom.readerAuthLogout?.addEventListener("click", logoutReaderAuthIdentity); + readerDom.readerAuthRequestVerification?.addEventListener("click", requestReaderEmailVerification); + readerDom.readerAuthRequestPasswordReset?.addEventListener("click", requestReaderPasswordReset); + readerDom.readerGrantEntitlement?.addEventListener("click", grantReaderEntitlement); + readerDom.readerStartCheckout?.addEventListener("click", () => startReaderCheckout()); + readerDom.readerManageSubscription?.addEventListener("click", openReaderCustomerPortal); + readerDom.readerRetryPayment?.addEventListener("click", retryReaderSubscriptionPayment); + readerDom.readerRenewSubscription?.addEventListener("click", renewReaderSubscription); + readerDom.readerCancelSubscription?.addEventListener("click", cancelReaderSubscription); + readerDom.readerQualityFeedbackPositive?.addEventListener("click", () => submitReaderQualityFeedback("thumbs_up")); + readerDom.readerQualityFeedbackNegative?.addEventListener("click", () => submitReaderQualityFeedback("thumbs_down")); + readerDom.readerIdInput?.addEventListener("change", refreshReaderEntitlements); + + for (const pill of readerDom.tonePills) { + pill.addEventListener("click", () => setTone(pill.dataset.tone)); + } +} + +function initializeReaderRuntime() { + if (readerRuntimeInitialized) return; + readerRuntimeInitialized = true; + + bindReaderEvents(); + restoreReaderAuthSession(); + restorePendingCheckoutContext(); + renderReaderAuthStatus(); + hydrateReaderAuthSession(); + + bootstrapHealth(); + if (readerDom.readerIdInput) { + readerDom.readerIdInput.value = readerDom.readerIdInput.value || readerState.readerId; + } + updateStatus(); + renderLatestStep(); + renderRoutePreview(); + renderReplay(); + renderIntentPrefill(); + refreshExamples().then(async () => { + if (readerState.pendingCheckoutStatus) { + await completePendingCheckoutReturn(); + } + if (shellState.pendingSessionId) { + await restoreSession(shellState.pendingSessionId); + } + }).catch((error) => { + reportUiMessage(`初始化示例书架失败:${error.message}`, "error"); + }); +} + + return { + activeReaderId, + mirrorReaderAuthSession, + refreshReaderEntitlements, + completePendingCheckoutReturn, + startReaderCheckout, + openReaderCustomerPortal, + registerReaderAuthIdentity, + loginReaderAuthIdentity, + requestReaderEmailVerification, + requestReaderPasswordReset, + hydrateReaderAuthSession, + logoutReaderAuthIdentity, + retryReaderSubscriptionPayment, + renewReaderSubscription, + cancelReaderSubscription, + grantReaderEntitlement, + renderIntentPrefill, + worldDisplayMeta, + renderWorldGallery, + renderSessionLibrary, + renderSuggestedInputs, + renderRoutePreview, + spotlightPreviewResult, + spotlightChapter, + setTone, + getStorySource, + renderStorybook, + renderLatestStep, + renderStoryFeed, + renderReplay, + updateBundleSummary, + loadExampleBundle, + refreshAuthoredWorkLibrary, + openAuthoredWorkPreview, + refreshExamples, + refreshSessionLibrary, + bootstrapWorld, + restoreSession, + deleteSession, + previewRoute, + stepSession, + resetOutput, + bootstrapHealth, + bindReaderEvents, + initializeReaderRuntime, + }; +})(); diff --git a/src/narrativeos/web/reader_accessors.js b/src/narrativeos/web/reader_accessors.js new file mode 100644 index 0000000..42816a3 --- /dev/null +++ b/src/narrativeos/web/reader_accessors.js @@ -0,0 +1,82 @@ +// Reader/account access helpers shared where membership, gating, and unlock labels are needed. + +var ReaderAccessors = (() => { + const TIER_LABELS = { + play_pass: "畅读会员", + creator_pass: "创作会员", + studio_pass: "工作室会员", + story_credits: "故事点数", + world_pass: "世界通行证", + trial: "试读", + }; + + function currentTierCatalog() { + return ( + readerState.readerSubscription?.tiers || + opsState.opsSubscriptionAudit?.tiers || + [] + ); + } + + function tierLabel(tierId) { + const tier = currentTierCatalog().find((item) => item.tier_id === tierId); + return TIER_LABELS[tierId] || TIER_LABELS[tier?.tier_id] || tier?.display_name || tierId || "-"; + } + + function accessReasonLabel(reason) { + return { + trial_chapter: "试读章节", + grace_window: "宽限章节", + continue_requires_entitlement: "需要更高权限", + subscriber_active: "会员已生效", + subscription_active: "会员已生效", + subscription_required: "需要创作会员或工作室会员", + entitlement_required: "需要解锁后继续阅读", + world_pass_active: "世界已解锁", + credits_balance: "故事点数可用", + credits_consumed: "已消耗故事点数", + credits_exhausted: "故事点数已耗尽", + studio_credits_balance: "创作点数可用", + studio_credits_exhausted: "创作点数已耗尽", + author_tier_required: "当前会员档位不支持创作", + entitlement_expired: "权益已过期", + missing_reader: "缺少 reader_id", + missing_account: "缺少 account_id", + }[reason] || reason || "-"; + } + + function worldUnlockLabel(paywall) { + if (!readerState.worldId) return "-"; + if (!paywall) return "试读中"; + if (paywall.entitlement_type === "subscriber") return `${paywall.tier_id || "会员"} 已解锁`; + if (paywall.entitlement_type === "world_pass") return "世界通行证已解锁"; + if (paywall.entitlement_type === "credits") return paywall.required ? "需消耗故事点数" : "故事点数可继续"; + if (!paywall.required) return "试读中"; + return "未解锁"; + } + + function gatingStatusLabel(access) { + if (!access) return "-"; + if (access.allowed === true || access.required === false) { + return "可用"; + } + return `受限 · ${accessReasonLabel(access.reason)}`; + } + + function gatingHint(access) { + if (!access) return "-"; + const tierText = access.required_display_name || tierLabel(access.required_tier); + const balanceText = access.balance !== null && access.balance !== undefined ? Number(access.balance).toFixed(0) : "-"; + const unitsText = access.required_units !== null && access.required_units !== undefined ? ` · 需要 ${Number(access.required_units).toFixed(0)}` : ""; + return `${gatingStatusLabel(access)} · ${tierText || "-"} · ${access.wallet_type || "-"} · 余额 ${balanceText}${unitsText}`; + } + + return { + currentTierCatalog, + tierLabel, + accessReasonLabel, + worldUnlockLabel, + gatingStatusLabel, + gatingHint, + }; +})(); diff --git a/src/narrativeos/web/reader_dom.js b/src/narrativeos/web/reader_dom.js new file mode 100644 index 0000000..1a501db --- /dev/null +++ b/src/narrativeos/web/reader_dom.js @@ -0,0 +1,112 @@ +// Reader-scoped DOM registry. + +var ReaderDOM = (() => ({ + readerLanding: DOMShared.query("#reader-landing"), + readerJumpSessions: DOMShared.query("#reader-jump-sessions"), + readerJumpWorlds: DOMShared.query("#reader-jump-worlds"), + readerJumpAuthoredWorks: DOMShared.query("#reader-jump-authored-works"), + readerStartCurrentWorld: DOMShared.query("#reader-start-current-world"), + readerLandingSummary: DOMShared.query("#reader-landing-summary"), + readerFeaturedStart: DOMShared.query("#reader-featured-start"), + readerReturnLanding: DOMShared.query("#reader-return-landing"), + turnStatus: DOMShared.query("#turn-status"), + worldStatus: DOMShared.query("#world-status"), + sessionStatus: DOMShared.query("#session-status"), + worldVersionStatus: DOMShared.query("#world-version-status"), + accessTierStatus: DOMShared.query("#access-tier-status"), + quoteStatus: DOMShared.query("#quote-status"), + paywallBanner: DOMShared.query("#paywall-banner"), + paywallBannerCopy: DOMShared.query("#paywall-banner-copy"), + paywallBannerCheckout: DOMShared.query("#paywall-banner-checkout"), + readerIdInput: DOMShared.query("#reader-id-input"), + readerAuthActorId: DOMShared.query("#reader-auth-actor-id"), + readerAuthDisplayName: DOMShared.query("#reader-auth-display-name"), + readerAuthPassword: DOMShared.query("#reader-auth-password"), + readerAuthRegister: DOMShared.query("#reader-auth-register"), + readerAuthLogin: DOMShared.query("#reader-auth-login"), + readerAuthLogout: DOMShared.query("#reader-auth-logout"), + readerAuthRequestVerification: DOMShared.query("#reader-auth-request-verification"), + readerAuthRequestPasswordReset: DOMShared.query("#reader-auth-request-password-reset"), + readerAuthStatus: DOMShared.query("#reader-auth-status"), + readerEntitlementType: DOMShared.query("#reader-entitlement-type"), + readerSubscriptionStatus: DOMShared.query("#reader-subscription-status"), + readerCreditBalance: DOMShared.query("#reader-credit-balance"), + readerWorldUnlockStatus: DOMShared.query("#reader-world-unlock-status"), + readerEntitlementReason: DOMShared.query("#reader-entitlement-reason"), + grantEntitlementType: DOMShared.query("#grant-entitlement-type"), + grantEntitlementBalance: DOMShared.query("#grant-entitlement-balance"), + readerRefreshEntitlements: DOMShared.query("#reader-refresh-entitlements"), + readerGrantEntitlement: DOMShared.query("#reader-grant-entitlement"), + readerStartCheckout: DOMShared.query("#reader-start-checkout"), + readerManageSubscription: DOMShared.query("#reader-manage-subscription"), + readerRetryPayment: DOMShared.query("#reader-retry-payment"), + readerRenewSubscription: DOMShared.query("#reader-renew-subscription"), + readerCancelSubscription: DOMShared.query("#reader-cancel-subscription"), + readerEntitlementList: DOMShared.query("#reader-entitlement-list"), + readerMembershipOffers: DOMShared.query("#reader-membership-offers"), + readerCheckoutStatus: DOMShared.query("#reader-checkout-status"), + readerAccessNote: DOMShared.query("#reader-access-note"), + readerDebugTools: DOMShared.query("#reader-debug-tools"), + worldGallery: DOMShared.query("#world-gallery"), + sessionLibrary: DOMShared.query("#session-library"), + readerAuthoredWorkLibrary: DOMShared.query("#reader-authored-work-library"), + previewRoute: DOMShared.query("#preview-route"), + stepSession: DOMShared.query("#step-session"), + resetOutput: DOMShared.query("#reset-output"), + readerComposerHint: DOMShared.query("#reader-composer-hint"), + viewExperience: DOMShared.query("#view-experience"), + viewStorybook: DOMShared.query("#view-storybook"), + viewBackstage: DOMShared.query("#view-backstage"), + experienceView: DOMShared.query("#experience-view"), + storybookView: DOMShared.query("#storybook-view"), + backstageView: DOMShared.query("#backstage-view"), + worldTitle: DOMShared.query("#world-title"), + worldDescription: DOMShared.query("#world-description"), + featuredWorld: DOMShared.query(".featured-world"), + featuredWorldTitle: DOMShared.query("#featured-world-title"), + featuredWorldCopy: DOMShared.query("#featured-world-copy"), + featuredWorldMood: DOMShared.query("#featured-world-mood"), + featuredWorldHook: DOMShared.query("#featured-world-hook"), + worldId: DOMShared.query("#world-id"), + sessionId: DOMShared.query("#session-id"), + lastEventTitle: DOMShared.query("#last-event-title"), + suggestedInputs: DOMShared.query("#suggested-inputs"), + playerInput: DOMShared.query("#player-input"), + currentPressureText: DOMShared.query("#current-pressure-text"), + lastIntentText: DOMShared.query("#last-intent-text"), + suggestedPrefillText: DOMShared.query("#suggested-prefill-text"), + factCount: DOMShared.query("#fact-count"), + promiseCount: DOMShared.query("#promise-count"), + tensionValue: DOMShared.query("#tension-value"), + sceneWindow: DOMShared.query("#scene-window"), + chosenEventTitle: DOMShared.query("#chosen-event-title"), + chapterPanel: DOMShared.query("#chapter-panel"), + bestRoute: DOMShared.query("#best-route"), + storyFeed: DOMShared.query("#story-feed"), + readerQualityFeedbackPanel: DOMShared.query("#reader-quality-feedback-panel"), + readerQualityFeedbackReason: DOMShared.query("#reader-quality-feedback-reason"), + readerQualityFeedbackNote: DOMShared.query("#reader-quality-feedback-note"), + readerQualityFeedbackPositive: DOMShared.query("#reader-quality-feedback-positive"), + readerQualityFeedbackNegative: DOMShared.query("#reader-quality-feedback-negative"), + routePreview: DOMShared.query("#route-preview"), + routePreviewPanel: DOMShared.query("#route-preview-panel"), + candidateSummary: DOMShared.query("#candidate-summary"), + scoredCandidates: DOMShared.query("#scored-candidates"), + criticTrace: DOMShared.query("#critic-trace"), + replayTimeline: DOMShared.query("#replay-timeline"), + storyHero: DOMShared.query("#story-hero"), + storyTitle: DOMShared.query("#story-title"), + storyCaption: DOMShared.query("#story-caption"), + storyRecap: DOMShared.query("#story-recap"), + storyQuote: DOMShared.query("#story-quote"), + storyPrompt: DOMShared.query("#story-prompt"), + storyMotif: DOMShared.query("#story-motif"), + storyBeats: DOMShared.query("#story-beats"), + storyDetails: DOMShared.query("#story-details"), + storyProse: DOMShared.query("#story-prose"), + storySequence: DOMShared.query("#story-sequence"), + readerBackstageClose: DOMShared.query("#reader-backstage-close"), + tonePills: [...DOMShared.queryAll(".tone-pill")], + suggestionTemplate: DOMShared.query("#suggested-input-template"), + listCardTemplate: DOMShared.query("#list-card-template"), +}))(); diff --git a/src/narrativeos/web/reader_shell_v2.js b/src/narrativeos/web/reader_shell_v2.js new file mode 100644 index 0000000..6c02dac --- /dev/null +++ b/src/narrativeos/web/reader_shell_v2.js @@ -0,0 +1,1332 @@ +// Reader shell v2. Uses the legacy Reader runtime as a data/actions adapter while replacing the visible Reader shell. + +var ReaderShellV2 = (() => { + const legacyReaderRuntime = typeof ReaderRuntime === "object" && ReaderRuntime ? ReaderRuntime : {}; + const legacyReaderDom = typeof ReaderDOM === "object" && ReaderDOM ? ReaderDOM : {}; + const shellDom = ShellDOM; + const dom = ReaderShellV2DOM; + const { + api, + clearNode, + createListCard, + formatTimestamp, + reportUiMessage, + setBusy, + } = UIShared; + const { tierLabel, accessReasonLabel } = ReaderAccessors; + + let readerShellV2Initialized = false; + let readerShellV2EventsBound = false; + + function escapeHtml(value) { + return String(value || "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + function firstImageUrl(...values) { + return values + .map((value) => String(value || "").trim()) + .find((value) => value.startsWith("/") || value.startsWith("http://") || value.startsWith("https://")) || ""; + } + + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + function readerGenerationJobId(payload) { + return String(payload?.jobId || payload?.job_id || "").trim(); + } + + function updateReaderGenerationJob(job, phase = "") { + if (!job) { + readerState.readerGenerationJob = null; + return; + } + readerState.readerGenerationJob = { + jobId: readerGenerationJobId(job), + status: String(job.status || ""), + readerStatus: String(job.readerStatus || job.result?.reader_status || ""), + phase, + error: job.error || "", + updatedAt: job.updatedAt || job.updated_at || new Date().toISOString(), + retryable: Boolean(job.retryable), + }; + } + + function readerGenerationPending() { + const status = String(readerState.readerGenerationJob?.status || ""); + return status === "queued" || status === "running"; + } + + function imageMarkup(imageUrl, altText, className) { + const safeUrl = firstImageUrl(imageUrl); + if (!safeUrl) return ""; + return ` +
+ ${escapeHtml(altText)} +
+ `; + } + + function setReaderShellMode() { + shellDom.appShell.dataset.readerShell = "v2"; + } + + function currentAccountId() { + if (typeof legacyReaderRuntime.activeReaderId === "function") { + return legacyReaderRuntime.activeReaderId(); + } + return readerState.readerId || "reader_demo"; + } + + function activeReaderPaywall() { + const latest = readerState.latestStep?.paywall || null; + if (latest?.required) return latest; + if (readerState.latestStepFailure?.paywall?.required) return readerState.latestStepFailure.paywall; + if (readerState.sessionPaywall?.required) return readerState.sessionPaywall; + return null; + } + + function activeReaderQualityFailure() { + if (readerState.continuityContract?.status === "quality_guard_failed" && readerState.latestStepFailure) { + return readerState.latestStepFailure; + } + return null; + } + + function currentReaderViewSource() { + if (readerState.latestStep?.reader_view) { + return readerState.latestStep.reader_view; + } + if (readerState.latestStepFailure?.reader_view) { + return readerState.latestStepFailure.reader_view; + } + return null; + } + + function readerChapterLabel(chapterIndex, fallbackTurnIndex = null) { + const numericIndex = Number(chapterIndex || 0); + if (numericIndex > 0) return `第 ${numericIndex} 章`; + const numericTurnIndex = Number(fallbackTurnIndex || 0); + if (numericTurnIndex > 0) return `Turn ${numericTurnIndex}`; + return "序章"; + } + + function selectedStorybookReplayIndex() { + const replayViews = readerState.replay?.reader_views || []; + if (!replayViews.length) return null; + if (readerState.selectedReplayIndex === null || readerState.selectedReplayIndex === undefined) { + return replayViews.length - 1; + } + const requestedIndex = Number(readerState.selectedReplayIndex); + if (Number.isInteger(requestedIndex) && requestedIndex >= 0 && requestedIndex < replayViews.length) { + return requestedIndex; + } + return replayViews.length - 1; + } + + function currentStorybookSource() { + const replayIndex = selectedStorybookReplayIndex(); + if (replayIndex !== null) { + return { + readerView: readerState.replay.reader_views[replayIndex], + renderedScene: readerState.replay.rendered_scenes?.[replayIndex] || null, + promiseSnapshot: readerState.replay.promise_ledger_snapshots?.[replayIndex] || [], + turnIndex: replayIndex + 1, + selectionIndex: replayIndex, + isPrologue: false, + }; + } + + const latestRecord = readerState.latestStep || readerState.latestStepFailure; + if (latestRecord?.reader_view) { + return { + readerView: latestRecord.reader_view, + renderedScene: latestRecord.rendered_scene || null, + promiseSnapshot: latestRecord.promise_ledger_snapshot || [], + turnIndex: Number( + readerState.replay?.event_trace?.length || + latestRecord.step_index || + latestRecord.reader_view.chapter_index || + 1 + ), + selectionIndex: null, + isPrologue: false, + }; + } + + if (readerState.sessionId && readerState.currentBundle) { + const worldMeta = selectedWorldMeta(); + const quote = readerState.currentBundle.player_inputs?.[0]?.raw_input || "先写下你真正想做的第一件事。"; + return { + readerView: { + chapter_title: `${readerWorldLabel()} · 序章`, + recap: "世界已经就位,真正的章节还没落笔。先稳住气味、代价和入口,再把第一幕推开。", + body: + `${readerState.currentBundle.description || "这个世界已经为你准备好了第一层情绪和第一道门槛。"}\n\n` + + "继续前,先确认你要把自己送进什么处境。", + relationship_hints: [worldMeta.mood, worldMeta.hook, "等待第一章"], + chapter_index: 0, + scene_card: { + quote, + summary: "先让入口动作带着情绪落地,再把真正的冲突推出来。", + palette_hint: worldMeta.mood, + story_beats: ["先确认要不要延续旧旅程", "把这一轮故事的入口动作写清楚", "让第一幕带着代价和悬念落下"], + visual_details: [worldMeta.mood, "序章态", "等待第一幕"], + }, + }, + renderedScene: null, + promiseSnapshot: [], + turnIndex: 0, + selectionIndex: null, + isPrologue: true, + }; + } + + return null; + } + + function storybookSequenceEntries(activeSource) { + const replayViews = readerState.replay?.reader_views || []; + if (replayViews.length) { + const activeReplayIndex = activeSource?.selectionIndex ?? selectedStorybookReplayIndex(); + const startIndex = Math.max(0, replayViews.length - 6); + return replayViews.slice(startIndex).map((item, offset) => { + const absoluteIndex = startIndex + offset; + return { + chapterTitle: item.chapter_title || `章节 ${absoluteIndex + 1}`, + recap: item.recap || "这一章暂时没有 recap。", + chapterIndex: item.chapter_index || absoluteIndex + 1, + turnIndex: absoluteIndex + 1, + relationshipHints: item.relationship_hints || [], + promiseCount: (readerState.replay.promise_ledger_snapshots?.[absoluteIndex] || []).length, + isActive: absoluteIndex === activeReplayIndex, + action: `jump-storybook:${absoluteIndex}`, + }; + }); + } + + if (!activeSource?.readerView) return []; + return [ + { + chapterTitle: activeSource.readerView.chapter_title || "当前章节", + recap: activeSource.readerView.recap || "当前阅读位置已经就位。", + chapterIndex: activeSource.readerView.chapter_index || 0, + turnIndex: activeSource.turnIndex || activeSource.readerView.chapter_index || 0, + relationshipHints: activeSource.readerView.relationship_hints || [], + promiseCount: (activeSource.promiseSnapshot || []).length, + isActive: true, + action: null, + }, + ]; + } + + function readerWorldLabel() { + if (readerState.currentBundle?.label) return readerState.currentBundle.label; + if (readerState.currentBundle?.world_bible?.title) return readerState.currentBundle.world_bible.title; + return "尚未选择世界"; + } + + function readerStatusItems() { + const subscription = readerState.readerSubscription?.subscription || {}; + const qualityFailure = activeReaderQualityFailure(); + const paywall = activeReaderPaywall(); + const creditBalance = Number(readerState.readerSubscription?.wallets?.story_credits?.balance || readerState.readerEntitlements?.find?.((item) => item.entitlement_type === "credits" && item.status === "active")?.balance || 0); + return [ + { label: "世界", value: readerWorldLabel() }, + { label: "旅程", value: readerState.sessionId ? `进行中` : "未创建" }, + { label: "当前视图", value: shellState.readerWorkspace === "read" ? (readerState.activeView || "experience") : "landing" }, + { label: "会员", value: subscription.tier_id ? tierLabel(subscription.tier_id) : "试读 / 未订阅" }, + { label: "故事点数", value: String(Number.isFinite(creditBalance) ? creditBalance.toFixed(0) : "-") }, + { label: "状态", value: readerGenerationPending() ? "生成中" : qualityFailure ? "quality_guard_failed" : paywall?.required ? accessReasonLabel(paywall.reason) : "ok" }, + ]; + } + + function renderStatusStrip() { + clearNode(dom.status); + readerStatusItems().forEach((item) => { + const chip = document.createElement("div"); + chip.className = "status-chip reader-shell-v2__status-chip"; + chip.innerHTML = `${escapeHtml(item.label)}${escapeHtml(item.value)}`; + dom.status.appendChild(chip); + }); + } + + function activeSessionCard() { + if (!readerState.sessionLibrary?.length) return null; + return (readerState.sessionLibrary || []).find((item) => item.session_id === readerState.sessionId) || readerState.sessionLibrary[0]; + } + + function activeCoverImage() { + const session = activeSessionCard(); + return firstImageUrl( + readerState.sessionMedia?.coverImage, + readerState.latestStep?.coverImage, + session?.coverImage + ); + } + + function activeAtmosphereImage() { + const session = activeSessionCard(); + return firstImageUrl( + readerState.sessionMedia?.atmosphereImage, + readerState.latestStep?.atmosphereImage, + session?.atmosphereImage, + activeCoverImage() + ); + } + + function narrativeHints() { + const readerView = currentReaderViewSource(); + return (readerView?.relationship_hints || []).filter(Boolean).slice(0, 4); + } + + function selectedWorldMeta() { + if (!readerState.currentBundle) { + return { + label: "尚未选择世界", + mood: "等待进入", + hook: "先挑一个世界,再开始旅程。", + }; + } + return worldDisplayMeta(readerState.currentBundle); + } + + function renderReaderShellHeadline() { + if (!dom.title || !dom.copy) return; + if (shellState.readerWorkspace === "landing") { + const activeSession = activeSessionCard(); + const worldMeta = selectedWorldMeta(); + dom.title.textContent = activeSession + ? "回到那一章停下来的地方,再决定要不要把这条命继续推下去。" + : "先挑一个世界,再把这一轮故事真正开出来。"; + dom.copy.textContent = activeSession + ? `当前最安全的动作是继续你已经打开的那段旅程;如果不想回去,再切到 ${worldMeta.label} 重新开篇。` + : `Reader shell v2 会先把世界、续读和解锁放到最前面。当前推荐世界是 ${worldMeta.label},它更偏向 ${worldMeta.mood}。`; + return; + } + if (readerState.activeView === "storybook") { + dom.title.textContent = currentStorybookSource()?.readerView?.chapter_title || "图文阅读"; + dom.copy.textContent = "把章节当成一张展开的画页来读:先看引句,再看节拍与余波,最后决定下一句心意。"; + return; + } + if (readerState.activeView === "backstage") { + dom.title.textContent = "幕后档案"; + dom.copy.textContent = "幕后档案只保留 Reader 真正需要的三个问题:当前章哪里最紧、下一步往哪偏、前几章留下了什么余波。"; + return; + } + dom.title.textContent = currentReaderViewSource()?.chapter_title || "沉浸阅读"; + dom.copy.textContent = "章节本身应该占据最大视觉权重;续写、解锁和恢复路径只在真正需要的时候出现。"; + } + + function createActionButton({ label, action, variant = "ghost-action", disabled = false }) { + return ``; + } + + function renderLandingCardList(id, title, description, items, emptyText) { + const section = document.createElement("section"); + section.id = id; + section.className = "panel reader-shell-v2__panel reader-shell-v2__collection"; + section.innerHTML = ` +
+

${escapeHtml(title)}

+

${escapeHtml(description)}

+
+ `; + const stack = document.createElement("div"); + stack.id = `${id}-list`; + stack.className = "reader-shell-v2__collection-list"; + section.appendChild(stack); + if (!items.length) { + clearNode(stack, emptyText); + return section; + } + items.forEach((item) => stack.appendChild(item)); + return section; + } + + function worldCards() { + return (readerState.examples || []).map((example) => { + const shelfWorld = (readerState.shelfWorlds || []).find((item) => item.world_id === example.world_id) || {}; + const meta = worldDisplayMeta(example); + const coverImage = firstImageUrl(shelfWorld.coverImage, example.coverImage); + const card = document.createElement("article"); + card.className = "list-card reader-shell-v2__world-card"; + if (readerState.currentBundle?.example_id === example.example_id) { + card.classList.add("is-active"); + } + card.innerHTML = ` + ${imageMarkup(coverImage, example.label || example.world_id || "世界封面", "reader-shell-v2__card-media")} +
+ 世界书架 + ${escapeHtml(shelfWorld.access_state || "trial")} +
+
+

${escapeHtml(example.label || example.world_id)}

+ ${escapeHtml(shelfWorld.risk_rating || "PG-13")} +
+

${escapeHtml(example.description || "进入这个世界前,先看清它当前的命题和准入状态。")}

+
+ ${escapeHtml(meta.mood)} + ${escapeHtml(meta.hook)} +
+
+ ${createActionButton({ label: "浏览世界", action: `preview-world:${example.example_id}` })} + ${createActionButton({ label: "开始旅程", action: `start-world:${example.example_id}`, variant: "primary-action" })} +
+ `; + return card; + }); + } + + function sessionCards() { + return (readerState.sessionLibrary || []).map((session) => { + const card = document.createElement("article"); + card.className = "list-card reader-shell-v2__session-card"; + if (readerState.sessionId === session.session_id) { + card.classList.add("is-active"); + } + const coverImage = firstImageUrl( + session.coverImage, + readerState.sessionId === session.session_id ? readerState.sessionMedia?.coverImage : "" + ); + card.innerHTML = ` + ${imageMarkup(coverImage, session.last_chapter_title || session.last_event_title || "旅程封面", "reader-shell-v2__card-media")} +
+ ${escapeHtml(readerState.sessionId === session.session_id ? "当前旅程" : "可安全续读")} + 第 ${escapeHtml(session.current_turn_index || 0)} 幕 +
+
+

${escapeHtml(session.last_chapter_title || session.last_event_title || "刚刚开始")}

+ ${escapeHtml(formatTimestamp(session.created_at))} +
+

停在你上次退出的那一章。最安全的动作是直接回去继续,而不是重新从书架开一段新旅程。

+
+ ${createActionButton({ label: "继续阅读", action: `resume-session:${session.session_id}`, variant: "primary-action" })} + ${createActionButton({ label: "删除旅程", action: `delete-session:${session.session_id}` })} +
+ `; + return card; + }); + } + + function authoredWorkCards() { + return (readerState.authoredWorkLibrary || []).map((work) => { + const chapterCount = Number(work.chapter_count || 0); + const card = document.createElement("article"); + card.className = "list-card reader-shell-v2__work-card"; + card.innerHTML = ` + ${imageMarkup(work.coverImage, work.title || work.work_id || "作品封面", "reader-shell-v2__card-media")} +
+ 作品入口 + ${escapeHtml(work.status || "draft")} +
+
+

${escapeHtml(work.title || work.work_id)}

+ ${chapterCount}/${escapeHtml(work.target_chapter_count || "-")} +
+

${chapterCount > 0 ? "Reader 保留作品入口,但不在这里展开创作语义;先把你送回正确的创作工作区。" : "还没有可读章节,回创作台继续生成。"}

+
+ ${createActionButton({ label: "去创作台", action: `open-author-work:${work.world_version_id || work.work_id}`, variant: "primary-action" })} +
+ `; + return card; + }); + } + + function renderLanding() { + renderReaderShellHeadline(); + renderStatusStrip(); + clearNode(dom.body); + const session = activeSessionCard(); + const worldMeta = selectedWorldMeta(); + const currentShelfWorld = (readerState.shelfWorlds || []).find( + (item) => item.world_id === readerState.currentBundle?.world_bible?.world_id + ) || {}; + const spotlightImage = session + ? firstImageUrl(session.coverImage, activeCoverImage()) + : firstImageUrl(currentShelfWorld.coverImage, readerState.currentBundle?.coverImage); + + const spotlight = document.createElement("section"); + spotlight.id = "reader-v2-spotlight"; + spotlight.className = "reader-shell-v2__spotlight panel"; + spotlight.innerHTML = session + ? ` +
+
+

继续这段旅程

+

${escapeHtml(session.last_chapter_title || session.last_event_title || "刚刚开始")}

+

${escapeHtml(`你已经在 ${readerWorldLabel()} 里停下了一次。现在最好的动作不是重开,而是回到那一章继续把这条命运往前推。`)}

+
+ ${createActionButton({ label: "继续阅读", action: `resume-session:${session.session_id}`, variant: "primary-action" })} + ${createActionButton({ label: "浏览世界", action: `preview-world:${readerState.currentBundle?.example_id || "demo"}` })} +
+
+
+ ${imageMarkup(spotlightImage, session.last_chapter_title || "旅程封面", "reader-shell-v2__spotlight-media")} +
+ 最近停在 + 第 ${escapeHtml(session.current_turn_index || 0)} 幕 +
+
+ 建立于 + ${escapeHtml(formatTimestamp(session.created_at))} +
+
+ 当前世界 + ${escapeHtml(readerWorldLabel())} +
+
+
+ ` + : ` +
+
+

开始一段新旅程

+

${escapeHtml(worldMeta.label)}

+

${escapeHtml(`当前推荐世界是 ${worldMeta.label}。它更偏向 ${worldMeta.mood},适合从“${worldMeta.hook}”这种入口切进去。`)}

+
+ ${createActionButton({ label: "开始旅程", action: `start-world:${readerState.currentBundle?.example_id || "demo"}`, variant: "primary-action" })} + ${createActionButton({ label: "浏览世界", action: `preview-world:${readerState.currentBundle?.example_id || "demo"}` })} +
+
+
+ ${imageMarkup(spotlightImage, worldMeta.label || "世界封面", "reader-shell-v2__spotlight-media")} +
+ 气质 + ${escapeHtml(worldMeta.mood)} +
+
+ 玩法 + ${escapeHtml(worldMeta.hook)} +
+
+ 状态 + 等待开篇 +
+
+
+ `; + dom.body.appendChild(spotlight); + + const grid = document.createElement("div"); + grid.className = "reader-shell-v2__grid"; + grid.appendChild(renderLandingCardList("reader-v2-sessions", "Reader · 续读", "你已经开始的旅程", sessionCards(), "还没有可恢复的旅程。")); + grid.appendChild(renderLandingCardList("reader-v2-worlds", "Reader · 世界", "从这里挑一个世界", worldCards(), "先读取世界入口,再决定从哪个世界起步。")); + grid.appendChild(renderLandingCardList("reader-v2-authored-works", "Reader · 我的作品", "回到创作台", authoredWorkCards(), "登录作者账号后,这里会显示可跳回创作台的作品入口。")); + dom.body.appendChild(grid); + } + + function renderReadHeader() { + const readerView = currentReaderViewSource(); + const coverImage = activeCoverImage(); + const section = document.createElement("section"); + section.id = "reader-v2-read-hero"; + section.className = `panel reader-shell-v2__read-hero${coverImage ? " reader-shell-v2__read-hero--with-media" : ""}`; + section.innerHTML = ` +
+
+

当前章节

+

${escapeHtml(readerView?.chapter_title || "旅程已启动")}

+

${escapeHtml(readerView?.recap || "从当前阅读位置继续,优先保留 session、视图和恢复路径。")}

+
+ ${imageMarkup(coverImage, readerView?.chapter_title || "当前旅程封面", "reader-shell-v2__read-hero-media")} +
+ ${createActionButton({ label: "返回书架", action: "return-landing" })} + ${createActionButton({ label: "沉浸阅读", action: "view:experience", variant: readerState.activeView === "experience" ? "primary-action" : "ghost-action" })} + ${createActionButton({ label: "图文阅读", action: "view:storybook", variant: readerState.activeView === "storybook" ? "primary-action" : "ghost-action" })} + ${createActionButton({ label: "幕后档案", action: "view:backstage", variant: readerState.activeView === "backstage" ? "primary-action" : "ghost-action" })} +
+
+
+ ${narrativeHints().map((hint) => `${escapeHtml(hint)}`).join("") || "这一章还没有额外关系提示。"} +
+ `; + return section; + } + + function buildReaderPaywallCard(paywall) { + if (!paywall?.required) return null; + const section = document.createElement("section"); + section.id = "reader-v2-paywall-card"; + section.className = "panel reader-shell-v2__panel reader-shell-v2__panel--warning"; + section.innerHTML = ` +
+

Reader · 解锁

+

${escapeHtml(accessReasonLabel(paywall.reason))}

+
+

继续前需要先解锁。推荐档位 ${escapeHtml(paywall.required_display_name || tierLabel(paywall.tier_id) || paywall.tier_id || "play_pass")},当前报价 ${paywall.quote !== undefined && paywall.quote !== null ? `¥${Number(paywall.quote).toFixed(2)}` : "-"}。Reader v2 会把解锁动作留在当前阅读路径里,而不是把你踢回书架。

+
+ ${createActionButton({ label: "解锁并继续阅读", action: `start-checkout:${paywall.suggested_checkout_tier || paywall.tier_id || "play_pass"}`, variant: "primary-action" })} +
+ `; + return section; + } + + function buildReaderQualityFailureCard(stepFailure) { + if (!stepFailure || stepFailure.status !== "quality_guard_failed") return null; + const gate = stepFailure.quality_gate || {}; + const issues = (gate.issues || []).map((item) => item.issue_code).filter(Boolean); + const section = document.createElement("section"); + section.id = "reader-v2-quality-card"; + section.className = "panel reader-shell-v2__panel reader-shell-v2__panel--warning"; + section.innerHTML = ` +
+

Reader · 质量守卫

+

当前章未入库,但 session 已保留

+
+

${escapeHtml(stepFailure.continuity_contract?.message || gate.summary || "系统保留了当前 session、视图和上一章内容,可以直接重试。")}

+
+ 当前文本 ${escapeHtml(gate.actual_text_units || 0)}/${escapeHtml(gate.required_text_units || "-")} + 主要问题 ${escapeHtml(issues.join(" / ") || gate.enforced_decision || "rewrite")} +
+
+ ${createActionButton({ label: "重试当前章", action: "retry-current-chapter", variant: "primary-action" })} +
+ `; + return section; + } + + function buildComposerCard() { + const section = document.createElement("section"); + const suggested = readerState.intentPrefill?.suggested_prefill || "我想先看看这条命会把我带去哪里。"; + const suggestions = (readerState.currentBundle?.player_inputs || []).slice(0, 3); + section.className = "panel reader-shell-v2__panel reader-shell-v2__composer"; + section.innerHTML = ` +
+

Reader · 下一句心意

+

读完这一章之后,你想先怎么做

+
+

${escapeHtml(readerState.intentPrefill?.current_pressure || "上一章留下的余波还没散。")}

+
+ ${suggestions.map((item) => ``).join("")} +
+ + +

推荐起笔:${escapeHtml(suggested)}

+
+ ${createActionButton({ label: "看看接下来", action: "preview-route" })} + ${createActionButton({ label: "推进这一幕", action: "step-session", variant: "primary-action", disabled: !readerState.sessionId })} + ${createActionButton({ label: "重置输入", action: "reset-output" })} +
+ `; + return section; + } + + function buildExperienceCard() { + const section = document.createElement("section"); + section.id = "reader-v2-experience"; + const readerView = currentReaderViewSource(); + section.className = "panel reader-shell-v2__panel reader-shell-v2__panel--story"; + section.innerHTML = ` +
+

沉浸阅读

+

${escapeHtml(readerView?.chapter_title || "故事还没开始")}

+
+
${escapeHtml(readerView?.body || "进入世界后,这里会显示当前章节正文。")}
+ `; + return section; + } + + function buildStorybookCard() { + const section = document.createElement("section"); + section.id = "reader-v2-storybook"; + const storySource = currentStorybookSource(); + const readerView = storySource?.readerView || null; + const renderedScene = storySource?.renderedScene || {}; + const sceneCard = readerView?.scene_card || {}; + const quote = sceneCard.quote || renderedScene.pull_quote || readerView?.recap || "这里会显示章节引句。"; + const quoteNote = sceneCard.summary || renderedScene.chapter_summary || readerView?.recap || "这一句会先把这一章最该记住的余波钉住。"; + const beatItems = (sceneCard.story_beats || renderedScene.story_beats || []).slice(0, 4); + const visualDetails = (sceneCard.visual_details || renderedScene.visual_details || []).slice(0, 4); + const relationshipHints = (readerView?.relationship_hints || []).filter(Boolean).slice(0, 4); + const canvasMeta = [ + readerChapterLabel(readerView?.chapter_index, storySource?.turnIndex), + storySource?.turnIndex ? `Turn ${storySource.turnIndex}` : null, + storySource?.promiseSnapshot?.length ? `未解牵挂 ${storySource.promiseSnapshot.length}` : null, + sceneCard.palette_hint || renderedScene.palette_hint || renderedScene.image_motif || null, + ].filter(Boolean); + const beatSummary = beatItems.length > 1 + ? `先抓住这 ${beatItems.length} 个节拍,再回到正文会更容易看清这一章怎么抬势、落子和留余波。` + : beatItems.length === 1 + ? "这一章最关键的动作先落在这一拍上。" + : "这一章还没有显式节拍,先从正文和引句里读余波。"; + const detailTags = [...relationshipHints, ...visualDetails] + .filter(Boolean) + .slice(0, 6); + const storyImage = storySource?.isPrologue ? activeCoverImage() : activeAtmosphereImage(); + const sequenceEntries = storybookSequenceEntries(storySource); + const sequenceSummary = sequenceEntries.length > 1 + ? `连续章节轨迹已累计 ${readerState.replay?.reader_views?.length || sequenceEntries.length} 章,当前停在 ${readerChapterLabel(readerView?.chapter_index, storySource?.turnIndex)}。` + : storySource?.isPrologue + ? "旅程刚启动,真正推进之后这里会把章节余波连成一条连续轨迹。" + : "继续推进之后,这里会把相邻章节的余波逐步连成一条连续轨迹。"; + section.className = "panel reader-shell-v2__panel reader-shell-v2__panel--story"; + section.innerHTML = ` +
+
+

图文阅读

+

${escapeHtml(readerView?.chapter_title || "图文版本")}

+
+
+ ${canvasMeta.map((item) => `${escapeHtml(item)}`).join("")} +
+
+
+
+

${escapeHtml(readerView?.recap || "这里会显示章节 recap。")}

+
${escapeHtml(readerView?.body || "这里会显示章节正文。")}
+
+
+ ${imageMarkup(storyImage, readerView?.chapter_title || "章节插图", "reader-shell-v2__image-panel")} +
+ +
${escapeHtml(quote)}
+

${escapeHtml(quoteNote)}

+
+
+
+ +

${escapeHtml(beatSummary)}

+
+
    + ${beatItems.length ? beatItems.map((beat, index) => ` +
  1. + ${String(index + 1).padStart(2, "0")} + ${escapeHtml(beat)} +
  2. + `).join("") : ` +
  3. + 这一章还没有显式节拍,先从正文和引句里读余波。 +
  4. + `} +
+
+
+ ${detailTags.length ? detailTags.map((hint) => `${escapeHtml(hint)}`).join("") : "这一章的关系和画面提示还在慢慢浮出来。"} +
+
+
+
+
+
+ +

${escapeHtml(sequenceSummary)}

+
+
+
+ ${sequenceEntries.length ? sequenceEntries.map((item) => { + const tag = item.action ? "button" : "article"; + const actionAttr = item.action ? ` data-reader-v2-action="${escapeHtml(item.action)}"` : ""; + const typeAttr = item.action ? " type=\"button\"" : ""; + const activeClass = item.isActive ? " is-active" : ""; + const secondaryMeta = [ + item.promiseCount ? `未解牵挂 ${item.promiseCount}` : null, + item.relationshipHints?.[0] || null, + ].filter(Boolean); + return ` + <${tag}${typeAttr} class="story-sequence-card reader-shell-v2__trajectory-card${activeClass}"${actionAttr}> + ${escapeHtml(item.isActive ? `${readerChapterLabel(item.chapterIndex, item.turnIndex)} · 当前阅读位置` : readerChapterLabel(item.chapterIndex, item.turnIndex))} + ${escapeHtml(item.chapterTitle)} +

${escapeHtml(item.recap)}

+ ${secondaryMeta.length ? `
${secondaryMeta.map((value) => `${escapeHtml(value)}`).join("")}
` : ""} + + `; + }).join("") : "

真正推进之后,这里会把最近几章压成一条可快速回看的轨迹。

"} +
+
+ `; + return section; + } + + function buildBackstageCard() { + const section = document.createElement("section"); + section.id = "reader-v2-backstage"; + section.className = "reader-shell-v2__backstage-drawer"; + const routes = (readerState.latestPreview?.routes || []).slice(0, 3); + const replayItems = (readerState.replay?.reader_views || []).slice(-3); + section.innerHTML = ` +
+
+

Reader · 幕后档案

+

当前阅读路径的轻量分析

+
+ +
+

这里只回答当前章哪里最紧、下一步往哪偏、前几章留下了什么余波,不把 Author / Ops 语义带进来。

+
+
+

Continuity

+

${escapeHtml(readerState.continuityContract?.message || "当前没有额外 continuity 提示。")}

+
+
+

Preview

+
${routes.length ? routes.map((route) => `

${escapeHtml(route.events?.[0]?.title || "下一步命运")}

${escapeHtml(Number(route.total_score || 0).toFixed(3))}

${escapeHtml(route.events?.[0]?.summary || route.explanation || "暂无说明。")}

`).join("") : "还没有 preview。先继续阅读或预览下一步。"}
+
+
+

Replay

+
${replayItems.length ? replayItems.map((item, index) => `

${escapeHtml(item.chapter_title || `章节 ${index + 1}`)}

${escapeHtml(item.chapter_index || index + 1)}

${escapeHtml(item.recap || "暂无 recap。")}

`).join("") : "当前还没有 replay 轨迹。"}
+
+
+ `; + return section; + } + + function renderRead() { + renderReaderShellHeadline(); + renderStatusStrip(); + clearNode(dom.body); + dom.body.appendChild(renderReadHeader()); + const qualityFailure = activeReaderQualityFailure(); + const paywall = activeReaderPaywall(); + const readingView = readerState.activeView === "backstage" + ? (shellState.lastReaderView === "storybook" ? "storybook" : "experience") + : readerState.activeView; + const readGrid = document.createElement("div"); + readGrid.id = "reader-v2-read-grid"; + readGrid.className = "reader-shell-v2__read-grid"; + + const main = document.createElement("div"); + main.id = "reader-v2-main-column"; + main.className = "reader-shell-v2__main-column"; + if (readingView === "storybook") { + main.appendChild(buildStorybookCard()); + } else { + main.appendChild(buildExperienceCard()); + } + + const side = document.createElement("div"); + side.id = "reader-v2-side-column"; + side.className = "reader-shell-v2__side-column"; + if (qualityFailure) { + side.appendChild(buildReaderQualityFailureCard(qualityFailure)); + } else if (paywall) { + side.appendChild(buildReaderPaywallCard(paywall)); + } + side.appendChild(buildComposerCard()); + + readGrid.appendChild(main); + readGrid.appendChild(side); + dom.body.appendChild(readGrid); + if (readerState.activeView === "backstage") { + dom.body.appendChild(buildBackstageCard()); + } + } + + function refresh() { + if (!dom.root) return; + setReaderShellMode(); + if (shellState.activeProduct !== "reader" || shellState.authPage) return; + if (shellState.readerWorkspace === "read") { + renderRead(); + return; + } + renderLanding(); + } + + async function refreshExamplesV2() { + if (typeof legacyReaderRuntime.refreshExamples === "function") { + await legacyReaderRuntime.refreshExamples(); + } + refresh(); + } + + async function refreshSessionLibraryV2() { + if (typeof legacyReaderRuntime.refreshSessionLibrary === "function") { + await legacyReaderRuntime.refreshSessionLibrary(); + } + refresh(); + } + + async function refreshAuthoredWorkLibraryV2() { + if (typeof legacyReaderRuntime.refreshAuthoredWorkLibrary === "function") { + await legacyReaderRuntime.refreshAuthoredWorkLibrary(); + } + refresh(); + } + + async function restoreSessionV2(sessionId, triggerButton = null) { + if (!sessionId) return; + const restore = triggerButton ? setBusy(triggerButton, "回到这一幕…") : () => {}; + try { + const sessionPayload = await api(`/v1/sessions/${sessionId}`); + const replayPayload = await api(`/v1/sessions/${sessionId}/replay`); + const prefillPayload = await api(`/v1/sessions/${sessionId}/prefill`); + const matchingExample = (readerState.examples || []).find((item) => item.world_id === sessionPayload.session.world_id); + if (matchingExample && readerState.currentBundle?.example_id !== matchingExample.example_id && typeof legacyReaderRuntime.loadExampleBundle === "function") { + await legacyReaderRuntime.loadExampleBundle(matchingExample.example_id); + } + readerState.sessionId = sessionId; + readerState.currentState = sessionPayload.session.current_state; + readerState.sessionPaywall = sessionPayload.paywall || readerState.sessionPaywall || null; + readerState.latestStep = sessionPayload.latest_step || null; + readerState.latestStepFailure = null; + readerState.readerGenerationJob = null; + readerState.continuityContract = null; + readerState.replay = replayPayload; + readerState.selectedReplayIndex = replayPayload.event_trace?.length + ? replayPayload.event_trace.length - 1 + : null; + readerState.intentPrefill = prefillPayload; + readerState.worldId = sessionPayload.session.world_id; + readerState.worldVersionId = sessionPayload.world_version_id || sessionPayload.session.metadata?.world_version_id || null; + readerState.readerId = sessionPayload.session.metadata?.reader_id || currentAccountId(); + shellState.pendingSessionId = null; + shellState.readerWorkspace = "read"; + readerState.activeView = "experience"; + await refreshReaderEntitlementsV2(); + refresh(); + if (typeof ShellStatusRuntime !== "undefined") { + ShellStatusRuntime.syncProductMode(); + } + } catch (error) { + reportUiMessage(`继续旅程失败:${error.message}`, "error"); + } finally { + restore(); + } + } + + async function bootstrapWorldV2(triggerButton = null) { + if (!readerState.currentBundle?.world_bible?.world_id) return; + const restore = triggerButton ? setBusy(triggerButton, "进入中…") : () => {}; + try { + const sessionResult = await api("/v1/reader/sessions", { + method: "POST", + body: JSON.stringify({ + world_id: readerState.currentBundle.world_bible.world_id, + account_id: currentAccountId(), + }), + }); + readerState.sessionId = sessionResult.session_id; + readerState.currentState = sessionResult.current_state; + readerState.sessionPaywall = sessionResult.paywall || null; + readerState.worldId = sessionResult.world_id || readerState.currentBundle.world_bible.world_id; + readerState.worldVersionId = sessionResult.world_version_id || null; + readerState.readerId = sessionResult.account_id || sessionResult.reader_id || currentAccountId(); + readerState.latestStep = null; + readerState.latestStepFailure = null; + readerState.readerGenerationJob = null; + readerState.continuityContract = null; + readerState.replay = null; + readerState.selectedReplayIndex = null; + readerState.intentPrefill = { + last_player_intent: "", + current_pressure: "故事刚刚开始。", + suggested_prefill: "我想先试探眼前这条路到底会把我带到哪一边。", + }; + shellState.readerWorkspace = "read"; + readerState.activeView = "experience"; + await refreshSessionLibraryV2(); + await refreshReaderEntitlementsV2(); + refresh(); + if (typeof ShellStatusRuntime !== "undefined") { + ShellStatusRuntime.syncProductMode(); + } + reportUiMessage("旅程已经开始,接下来可以先阅读当前章,再写下一句心意。", "success"); + } catch (error) { + reportUiMessage(`开始旅程失败:${error.message}`, "error"); + } finally { + restore(); + } + } + + async function pollReaderGenerationJob(initialJob) { + let job = initialJob || {}; + const jobId = readerGenerationJobId(job); + if (!jobId) { + const error = new Error("reader_generation_job_missing"); + error.code = "reader_job_missing"; + throw error; + } + const timeoutMs = 60000; + const resumeAfterMs = 5000; + const start = Date.now(); + let resumed = false; + while (Date.now() - start < timeoutMs) { + updateReaderGenerationJob(job, resumed ? "resumed" : "polling"); + refresh(); + const status = String(job.status || ""); + if (status === "succeeded") { + return job; + } + if (status === "failed") { + const error = new Error(job.error || "reader_generation_job_failed"); + error.code = "reader_job_failed"; + error.job = job; + throw error; + } + const shouldResume = + !resumed && + Boolean(job.retryable) && + Date.now() - start >= resumeAfterMs && + (status === "queued" || status === "running"); + if (shouldResume) { + const resumedPayload = await api(`/v1/reader/jobs/${encodeURIComponent(jobId)}/resume`, { + method: "POST", + }); + job = resumedPayload.job || job; + resumed = true; + continue; + } + await sleep(Number(job.pollAfterMs || 1000)); + const payload = await api(`/v1/reader/jobs/${encodeURIComponent(jobId)}`); + job = payload.job || job; + } + const error = new Error(`reader_generation_job_timeout:${jobId}`); + error.code = "reader_job_timeout"; + error.job = job; + throw error; + } + + async function reloadReaderSessionAfterGeneration(job) { + const sessionId = String(job?.sessionId || readerState.sessionId || "").trim(); + if (!sessionId) { + const error = new Error("reader_generation_session_missing_after_job"); + error.code = "reader_ui_sync_stale"; + throw error; + } + const sessionPayload = await api(`/v1/sessions/${encodeURIComponent(sessionId)}`); + const replayPayload = await api(`/v1/sessions/${encodeURIComponent(sessionId)}/replay`); + const prefillPayload = await api(`/v1/sessions/${encodeURIComponent(sessionId)}/prefill`); + readerState.sessionId = sessionId; + readerState.currentState = sessionPayload.session?.current_state || readerState.currentState; + readerState.sessionPaywall = sessionPayload.paywall || job?.result?.paywall || readerState.sessionPaywall || null; + readerState.worldId = sessionPayload.session?.world_id || readerState.worldId; + readerState.worldVersionId = sessionPayload.world_version_id || sessionPayload.session?.metadata?.world_version_id || readerState.worldVersionId; + readerState.readerId = sessionPayload.session?.metadata?.reader_id || readerState.readerId || currentAccountId(); + readerState.replay = replayPayload; + readerState.selectedReplayIndex = replayPayload.event_trace?.length ? replayPayload.event_trace.length - 1 : null; + readerState.intentPrefill = prefillPayload; + readerState.continuityContract = job?.result?.continuity_contract || null; + const readerStatus = String(job?.readerStatus || job?.result?.reader_status || "ok"); + if (readerStatus === "quality_guard_failed") { + readerState.latestStepFailure = { + status: "quality_guard_failed", + quality_gate: job?.result?.quality_gate || null, + continuity_contract: job?.result?.continuity_contract || null, + paywall: job?.result?.paywall || null, + }; + } else { + readerState.latestStepFailure = null; + } + readerState.latestStep = sessionPayload.latest_step || null; + updateReaderGenerationJob(job, "succeeded"); + await refreshSessionLibraryV2(); + await refreshReaderEntitlementsV2(); + readerState.readerGenerationJob = null; + refresh(); + if (readerStatus === "quality_guard_failed") { + reportUiMessage(job?.result?.continuity_contract?.message || "当前章未入库,但阅读位置已保留。", "warning"); + return; + } + if (!readerState.latestStep) { + const error = new Error("reader_ui_sync_stale"); + error.code = "reader_ui_sync_stale"; + error.job = job; + throw error; + } + reportUiMessage("这一幕已经推进,新的章节和回放都已更新。", "success"); + } + + async function stepSessionV2() { + if (!readerState.sessionId) return; + const input = dom.root?.querySelector("#reader-shell-v2-input"); + const playerInput = String(input?.value || "").trim(); + if (!playerInput) { + reportUiMessage("先写下一句你现在真正想做的事。", "warning"); + return; + } + if (legacyReaderDom.playerInput) { + legacyReaderDom.playerInput.value = playerInput; + } + const stepButton = dom.root?.querySelector('[data-reader-v2-action="step-session"]'); + const restore = stepButton ? setBusy(stepButton, "执行中…") : () => {}; + try { + const stepResult = await api("/v1/reader/continue", { + method: "POST", + body: JSON.stringify({ + session_id: readerState.sessionId, + account_id: currentAccountId(), + freeform_intent: playerInput, + }), + }); + if (stepResult.status === "queued" && stepResult.job) { + updateReaderGenerationJob(stepResult.job, "queued"); + refresh(); + reportUiMessage("生成中,完成后会自动更新阅读位置。", "info"); + const completedJob = await pollReaderGenerationJob(stepResult.job); + await reloadReaderSessionAfterGeneration(completedJob); + return; + } + readerState.continuityContract = stepResult.continuity_contract || null; + readerState.readerGenerationJob = null; + if (stepResult.status === "quality_guard_failed") { + readerState.latestStepFailure = stepResult; + readerState.sessionPaywall = stepResult.paywall || readerState.sessionPaywall || null; + await refreshReaderEntitlementsV2(); + refresh(); + reportUiMessage(stepResult.continuity_contract?.message || "当前章未入库,但阅读位置已保留。", "warning"); + return; + } + if (stepResult.status && stepResult.status !== "ok") { + readerState.latestStepFailure = null; + readerState.sessionPaywall = stepResult.paywall || readerState.sessionPaywall || null; + await refreshReaderEntitlementsV2(); + refresh(); + reportUiMessage(stepResult.continuity_contract?.message || `继续前需要先解锁:${accessReasonLabel(stepResult.paywall?.reason)}。`, "warning"); + return; + } + readerState.latestStep = stepResult; + readerState.latestStepFailure = null; + readerState.currentState = stepResult.updated_state; + readerState.worldVersionId = stepResult.world_version_id || readerState.worldVersionId; + readerState.sessionPaywall = stepResult.paywall || readerState.sessionPaywall || null; + readerState.replay = await api(`/v1/sessions/${readerState.sessionId}/replay`); + readerState.selectedReplayIndex = readerState.replay?.event_trace?.length + ? readerState.replay.event_trace.length - 1 + : null; + readerState.intentPrefill = await api(`/v1/sessions/${readerState.sessionId}/prefill`); + await refreshSessionLibraryV2(); + await refreshReaderEntitlementsV2(); + refresh(); + reportUiMessage("这一幕已经推进,新的章节和回放都已更新。", "success"); + } catch (error) { + reportUiMessage(`这一幕没能推进:${error.message}`, "error"); + } finally { + restore(); + } + } + + function resetOutputV2() { + readerState.latestStep = null; + readerState.latestStepFailure = null; + readerState.readerGenerationJob = null; + readerState.continuityContract = null; + readerState.replay = null; + readerState.intentPrefill = null; + readerState.selectedReplayIndex = null; + if (legacyReaderDom.playerInput) { + legacyReaderDom.playerInput.value = ""; + } + refresh(); + } + + async function refreshReaderEntitlementsV2() { + if (typeof legacyReaderRuntime.refreshReaderEntitlements === "function") { + await legacyReaderRuntime.refreshReaderEntitlements(); + } + refresh(); + } + + async function startReaderCheckoutV2(tierId = "play_pass") { + if (typeof legacyReaderRuntime.startReaderCheckout === "function") { + await legacyReaderRuntime.startReaderCheckout(tierId); + } + refresh(); + } + + async function restoreCheckoutContext() { + if (typeof legacyReaderRuntime.completePendingCheckoutReturn === "function") { + await legacyReaderRuntime.completePendingCheckoutReturn(); + } + refresh(); + } + + async function deleteSessionV2(sessionId) { + if (typeof legacyReaderRuntime.deleteSession === "function") { + await legacyReaderRuntime.deleteSession(sessionId); + } + refresh(); + } + + async function openAuthorWorkV2(worldVersionId) { + shellState.activeProduct = "author"; + shellState.authorWorkspace = "draft"; + authorState.activeDraftVersionId = worldVersionId || authorState.activeDraftVersionId; + if (typeof ShellStatusRuntime !== "undefined") { + ShellStatusRuntime.syncProductMode(); + } + if (typeof AuthorWorkspaceRuntime !== "undefined" && typeof AuthorWorkspaceRuntime.refreshAuthorSurface === "function") { + await AuthorWorkspaceRuntime.refreshAuthorSurface(); + } + } + + async function handleAction(action) { + if (!action) return; + if (action === "return-landing") { + shellState.readerWorkspace = "landing"; + refresh(); + if (typeof ShellStatusRuntime !== "undefined") { + ShellStatusRuntime.syncProductMode(); + } + return; + } + if (action === "step-session") { + await stepSessionV2(); + return; + } + if (action === "preview-route") { + if (typeof legacyReaderRuntime.previewRoute === "function") { + await legacyReaderRuntime.previewRoute(); + } + refresh(); + return; + } + if (action === "retry-current-chapter") { + await stepSessionV2(); + return; + } + if (action === "close-backstage") { + readerState.activeView = shellState.lastReaderView || "experience"; + refresh(); + if (typeof ShellStatusRuntime !== "undefined") { + ShellStatusRuntime.syncViewMode(); + } + return; + } + if (action === "reset-output") { + resetOutputV2(); + return; + } + if (action.startsWith("preview-world:")) { + const exampleId = action.split(":")[1]; + if (typeof legacyReaderRuntime.loadExampleBundle === "function") { + await legacyReaderRuntime.loadExampleBundle(exampleId); + } + refresh(); + return; + } + if (action.startsWith("start-world:")) { + const exampleId = action.split(":")[1]; + if (typeof legacyReaderRuntime.loadExampleBundle === "function") { + await legacyReaderRuntime.loadExampleBundle(exampleId); + } + const trigger = dom.root?.querySelector(`[data-reader-v2-action="${action}"]`); + await bootstrapWorldV2(trigger); + return; + } + if (action.startsWith("resume-session:")) { + const sessionId = action.split(":")[1]; + const trigger = dom.root?.querySelector(`[data-reader-v2-action="${action}"]`); + await restoreSessionV2(sessionId, trigger); + return; + } + if (action.startsWith("delete-session:")) { + await deleteSessionV2(action.split(":")[1]); + return; + } + if (action.startsWith("open-author-work:")) { + await openAuthorWorkV2(action.split(":")[1]); + return; + } + if (action.startsWith("start-checkout:")) { + await startReaderCheckoutV2(action.split(":")[1]); + return; + } + if (action.startsWith("suggestion:")) { + const value = action.slice("suggestion:".length); + const input = dom.root?.querySelector("#reader-shell-v2-input"); + if (input) { + input.value = value; + } + if (legacyReaderDom.playerInput) { + legacyReaderDom.playerInput.value = value; + } + return; + } + if (action.startsWith("jump-storybook:")) { + const nextIndex = Number(action.split(":")[1]); + if (Number.isInteger(nextIndex) && nextIndex >= 0) { + readerState.selectedReplayIndex = nextIndex; + readerState.activeView = "storybook"; + refresh(); + if (typeof ShellStatusRuntime !== "undefined") { + ShellStatusRuntime.syncViewMode(); + } + } + return; + } + if (action.startsWith("view:")) { + readerState.activeView = action.split(":")[1]; + refresh(); + if (typeof ShellStatusRuntime !== "undefined") { + ShellStatusRuntime.syncViewMode(); + } + } + } + + function bindReaderShellV2Events() { + if (readerShellV2EventsBound || !dom.root) return; + readerShellV2EventsBound = true; + dom.root.addEventListener("click", async (event) => { + const target = event.target.closest("[data-reader-v2-action]"); + if (!target) return; + event.preventDefault(); + try { + await handleAction(target.dataset.readerV2Action); + } catch (error) { + reportUiMessage(`Reader shell v2 操作失败:${error.message}`, "error"); + } + }); + } + + function scheduleBootstrapRefresh() { + [0, 250, 1000].forEach((delay) => { + window.setTimeout(() => { + refresh(); + }, delay); + }); + } + + function initializeReaderRuntime() { + if (readerShellV2Initialized) return; + readerShellV2Initialized = true; + if (typeof legacyReaderRuntime.initializeReaderRuntime === "function") { + legacyReaderRuntime.initializeReaderRuntime(); + } + setReaderShellMode(); + bindReaderShellV2Events(); + scheduleBootstrapRefresh(); + refresh(); + } + + const apiSurface = { + ...legacyReaderRuntime, + initialize: initializeReaderRuntime, + refresh, + renderLanding, + renderRead, + renderStorybook: refresh, + renderBackstage: refresh, + restoreCheckoutContext, + initializeReaderRuntime, + refreshExamples: refreshExamplesV2, + refreshSessionLibrary: refreshSessionLibraryV2, + refreshAuthoredWorkLibrary: refreshAuthoredWorkLibraryV2, + refreshReaderEntitlements: refreshReaderEntitlementsV2, + bootstrapWorld: bootstrapWorldV2, + restoreSession: restoreSessionV2, + stepSession: stepSessionV2, + resetOutput: resetOutputV2, + startReaderCheckout: startReaderCheckoutV2, + deleteSession: deleteSessionV2, + renderLatestStep: refresh, + renderReplay: refresh, + renderIntentPrefill: refresh, + renderStoryFeed: refresh, + renderRoutePreview: refresh, + bindReaderEvents: bindReaderShellV2Events, + }; + + return apiSurface; +})(); + +if (typeof window !== "undefined") { + window.ReaderRuntimeLegacy = typeof ReaderRuntime === "object" && ReaderRuntime ? ReaderRuntime : null; + window.ReaderShellV2 = ReaderShellV2; + window.ReaderRuntime = ReaderShellV2; +} diff --git a/src/narrativeos/web/reader_shell_v2_dom.js b/src/narrativeos/web/reader_shell_v2_dom.js new file mode 100644 index 0000000..4b236ed --- /dev/null +++ b/src/narrativeos/web/reader_shell_v2_dom.js @@ -0,0 +1,9 @@ +// Reader v2 shell DOM registry. Keeps the new shell isolated from legacy Reader DOM. + +var ReaderShellV2DOM = (() => ({ + root: DOMShared.query("#reader-shell-v2"), + title: DOMShared.query("#reader-shell-v2-title"), + copy: DOMShared.query("#reader-shell-v2-copy"), + status: DOMShared.query("#reader-shell-v2-status"), + body: DOMShared.query("#reader-shell-v2-body"), +}))(); diff --git a/src/narrativeos/web/route_sync_runtime.js b/src/narrativeos/web/route_sync_runtime.js new file mode 100644 index 0000000..0ed8f30 --- /dev/null +++ b/src/narrativeos/web/route_sync_runtime.js @@ -0,0 +1,217 @@ +// Shell route synchronization extracted from app.js. + +var RouteSyncRuntime = (() => { + const readerDom = ReaderDOM; + const authorDom = AuthorDOM; + const opsDom = OpsDOM; + + function shellPathProfile() { + if (typeof window === "undefined") return {}; + const path = window.location.pathname.replace(/\/+$/, ""); + if (path === "/app/login") { + return { + shell_kind: "auth", + auth_page: "login", + }; + } + if (path === "/app/signup") { + return { + shell_kind: "auth", + auth_page: "signup", + }; + } + if (path === "/app/verify-email") { + return { + shell_kind: "auth", + auth_page: "verify-email", + }; + } + if (path === "/app/forgot-password") { + return { + shell_kind: "auth", + auth_page: "forgot-password", + }; + } + if (path === "/app/reset-password") { + return { + shell_kind: "auth", + auth_page: "reset-password", + }; + } + if (path === "/app/user") { + return { + product: "author", + workspace: "draft", + shell_kind: "user", + }; + } + if (path === "/app/reviewer") { + return { + product: "ops", + workspace: "review", + shell_kind: "reviewer", + }; + } + if (path === "/app/customer") { + return { + product: "customer", + workspace: "overview", + shell_kind: "customer", + }; + } + return { + shell_kind: "shared", + }; + } + + function currentProductWorkspace() { + if (shellState.activeProduct === "author") return shellState.authorWorkspace; + if (shellState.activeProduct === "customer") return shellState.customerWorkspace; + if (shellState.activeProduct === "ops") return shellState.opsWorkspace; + return shellState.readerWorkspace; + } + + function resolveActiveAccountId() { + if (shellState.activeProduct === "reader") { + return typeof ReaderRuntime !== "undefined" && typeof ReaderRuntime.activeReaderId === "function" + ? ReaderRuntime.activeReaderId() + : (readerDom.readerIdInput?.value || "").trim(); + } + if (shellState.activeProduct === "author") { + return (authorDom.authorAccountId?.value || "").trim(); + } + if (shellState.activeProduct === "customer") { + return String(authorState.authorAuthSession?.identity?.account_id || authorState.authorAuthSession?.identity?.actor_id || "").trim(); + } + return (opsDom.opsAccountId?.value || "").trim(); + } + + function syncShellRoute() { + if (typeof window === "undefined") return; + if (shellState.authPage) return; + const params = new URLSearchParams(); + params.set("product", shellState.activeProduct); + params.set("workspace", currentProductWorkspace()); + if (shellState.activeProduct === "reader" && shellState.readerWorkspace === "read") { + params.set("view", readerState.activeView); + } + if (shellState.activeProduct === "ops") { + const opsWorldId = (opsDom.opsNavWorldId?.value || opsDom.opsReleaseWorldId?.value || "").trim(); + const opsCaseId = (opsDom.opsNavCaseId?.value || opsDom.opsGovernanceCaseId?.value || "").trim(); + const opsAlertId = (opsDom.opsNavAlertId?.value || "").trim(); + const opsWorldVersionId = (opsDom.opsInvestigationWorldVersionId?.value || "").trim(); + if (opsWorldId) params.set("world_id", opsWorldId); + if (opsCaseId) params.set("case_id", opsCaseId); + if (opsAlertId) params.set("alert_id", opsAlertId); + if (opsWorldVersionId) params.set("world_version_id", opsWorldVersionId); + } else if (shellState.activeProduct === "reader" && readerState.worldId) { + params.set("world_id", readerState.worldId); + } + if (readerState.sessionId) params.set("session_id", readerState.sessionId); + if (authorState.activeDraftVersionId) params.set("draft_id", authorState.activeDraftVersionId); + const accountId = resolveActiveAccountId(); + if (accountId) params.set("account_id", accountId); + if (shellState.debug) params.set("debug", "1"); + if (shellState.adminViewEnabled) params.set("admin_view", "1"); + const query = params.toString(); + const url = `${window.location.pathname}${query ? `?${query}` : ""}`; + window.history.replaceState({}, "", url); + } + + function hydrateShellRoute() { + if (typeof window === "undefined") return; + const pathProfile = shellPathProfile(); + const params = new URLSearchParams(window.location.search); + const product = params.get("product"); + const workspace = params.get("workspace"); + const view = params.get("view"); + const debug = params.get("debug"); + const adminView = params.get("admin_view"); + const adminViewBridge = params.get("admin_view_bridge"); + const checkoutStatus = params.get("checkout"); + const checkoutSessionId = params.get("checkout_session_id") || (checkoutStatus ? params.get("session_id") : null); + shellState.authPage = pathProfile.auth_page || null; + const requestedProduct = pathProfile.product || (product && ["reader", "author", "customer", "ops"].includes(product) ? product : null); + const requestedWorkspace = pathProfile.workspace || workspace || null; + if (requestedProduct) { + shellState.startupRouteProduct = requestedProduct; + } + if (requestedWorkspace) { + shellState.startupRouteWorkspace = requestedWorkspace; + } + if (pathProfile.product) { + shellState.activeProduct = pathProfile.product; + } else if (product && ["reader", "author", "customer", "ops"].includes(product)) { + shellState.activeProduct = product; + } + if (pathProfile.workspace) { + if (shellState.activeProduct === "reader") shellState.readerWorkspace = pathProfile.workspace; + if (shellState.activeProduct === "author") shellState.authorWorkspace = pathProfile.workspace; + if (shellState.activeProduct === "customer") shellState.customerWorkspace = pathProfile.workspace; + if (shellState.activeProduct === "ops") shellState.opsWorkspace = pathProfile.workspace; + } else if (workspace) { + if (shellState.activeProduct === "reader") shellState.readerWorkspace = workspace; + if (shellState.activeProduct === "author") shellState.authorWorkspace = workspace; + if (shellState.activeProduct === "customer") shellState.customerWorkspace = workspace; + if (shellState.activeProduct === "ops") shellState.opsWorkspace = workspace; + } + if (view && ["experience", "storybook", "backstage"].includes(view)) { + readerState.activeView = view; + } + shellState.debug = debug === "1" || debug === "true"; + shellState.adminViewEnabled = adminView === "1" || adminView === "true"; + shellState.adminViewBridgeToken = + (adminViewBridge ? String(adminViewBridge) : "") || + (shellState.activeProduct === "ops" && typeof window !== "undefined" + ? window.sessionStorage.getItem("narrativeos_admin_view_bridge") || null + : null); + if (params.get("draft_id")) { + authorState.activeDraftVersionId = params.get("draft_id"); + } + if (params.get("world_id")) { + const worldId = params.get("world_id"); + if (shellState.activeProduct === "ops") { + opsState.selectedOpsWorldId = worldId; + if (opsDom.opsNavWorldId) opsDom.opsNavWorldId.value = worldId; + if (opsDom.opsReleaseWorldId) opsDom.opsReleaseWorldId.value = worldId; + } else if (shellState.activeProduct === "reader") { + readerState.worldId = worldId; + } + } + if (shellState.activeProduct === "ops" && params.get("case_id")) { + const caseId = params.get("case_id"); + if (opsDom.opsNavCaseId) opsDom.opsNavCaseId.value = caseId; + if (opsDom.opsGovernanceCaseId) opsDom.opsGovernanceCaseId.value = caseId; + if (opsDom.opsInvestigationCaseId) opsDom.opsInvestigationCaseId.value = caseId; + } + if (shellState.activeProduct === "ops" && params.get("alert_id")) { + const alertId = params.get("alert_id"); + opsState.selectedOpsAlertId = alertId; + if (opsDom.opsNavAlertId) opsDom.opsNavAlertId.value = alertId; + } + if (shellState.activeProduct === "ops" && params.get("world_version_id")) { + const worldVersionId = params.get("world_version_id"); + if (opsDom.opsInvestigationWorldVersionId) opsDom.opsInvestigationWorldVersionId.value = worldVersionId; + } + if (checkoutStatus) { + readerState.pendingCheckoutStatus = checkoutStatus; + readerState.pendingCheckoutSessionId = checkoutSessionId; + shellState.activeProduct = "reader"; + } else if (params.get("session_id")) { + shellState.pendingSessionId = params.get("session_id"); + shellState.readerWorkspace = "read"; + } + if (params.get("account_id")) { + const accountId = params.get("account_id"); + if (readerDom.readerIdInput) readerDom.readerIdInput.value = accountId; + if (authorDom.authorAccountId && shellState.activeProduct === "author") authorDom.authorAccountId.value = accountId; + if (opsDom.opsAccountId && shellState.activeProduct === "ops") opsDom.opsAccountId.value = accountId; + } + } + + return { + currentProductWorkspace, + syncShellRoute, + hydrateShellRoute, + }; +})(); diff --git a/src/narrativeos/web/shell_bootstrap_runtime.js b/src/narrativeos/web/shell_bootstrap_runtime.js new file mode 100644 index 0000000..52005d0 --- /dev/null +++ b/src/narrativeos/web/shell_bootstrap_runtime.js @@ -0,0 +1,260 @@ +// Bootstrap exposure layer extracted from app.js so classic scripts consume a focused assembly surface. + +var { + hydrateShellRoute, + syncShellRoute, +} = RouteSyncRuntime; + +var { + setReaderWorkspace, + setAuthorWorkspace, + setOpsWorkspace, + installScrollWorkspaceBridge, +} = WorkspaceLayoutRuntime; + +var { + syncViewMode, + syncProductMode, + updateStatus, + toggleDebugMode, +} = ShellStatusRuntime; + +var { + authorStageLabel, + focusAuthorPanel, + prefillAuthorCommentAnchor, + jumpToAuthorChapter, + activeAuthorReviewerId, + activeAuthorActorId, + activeAuthorActorRole, + currentAuthorInboxFilters, + authorCollaborationHeaders, + selectAuthorThread, + mergeAuthorReviewerInbox, + syncAuthorNotificationPreferenceInputs, + persistAuthorAuthSession, + restoreAuthorAuthSession, + renderAuthorAuthStatus, + refreshAuthorReviewerInbox, + updateAuthorThreadStatusInline, + updateAuthorNotificationStatus, + bulkUpdateAuthorNotificationStatus, + decideAuthorApprovalForWorld, + addAuthorThreadWatcher, + removeAuthorThreadWatcher, + replyToSelectedAuthorThread, + addAuthorDraftWatcher, + removeAuthorDraftWatcher, + refreshAuthorNotificationPreferences, + saveAuthorNotificationPreference, + registerAuthorAuthIdentity, + loginAuthorAuthIdentity, + hydrateAuthorAuthSession, + logoutAuthorAuthIdentity, + validateDraftVersion, + simulateDraftVersion, + submitDraftVersion, + createAuthorCommentThread, + requestAuthorApproval, + decideAuthorApproval, + runAuthorWorkflowAction, + populateAuthorBriefForm, + applyAuthorPresetDefaults, + buildAuthorBriefPayload, + getActiveDraftCharacters, + getActiveDraftScenes, + getActiveSeriesPlan, + getActiveVolumePlans, + getActiveArcPlans, + applyDraftWorldpackMutation, + resequenceArcOrders, + reorderArcWithinVolume, + reorderTaskWithinArc, + moveTaskAcrossArcs, + selectedCharacterIndex, + selectedSceneIndex, + renderAuthorDraftDetail, + renderCharacterEditor, + renderSceneEditor, + renderLongformWorkbench, + renderPromiseLedgerWorkbench, + renderSeriesVolumeArcPromiseMapping, + renderChapterTaskSimulationLinking, + renderRewritePatchPreview, + renderSimulationDiffCheckpoint, + exportRewritePatchPreview, + runCheckpointAwareResimulate, + splitSelectedTaskPromiseTargets, + mergeObservedPromisesIntoTargets, + applySelectedTaskRewritePrefill, + bulkApplyTaskToSimulation, + renderContinuityDiffWorkbench, + parseMultilineList, + splitPromiseTargetList, + normalizeLongformArcTasks, + formatMultilineList, + parseLabelMap, + formatLabelMap, + parseSceneHooks, + formatSceneHooks, + renderStylePacingHookControls, + applyStylePacingHookControls, + buildSimulationDiffSummary, + renderAuthorRevisionPanels, + renderAuthorDrafts, + renderAuthorWorkflow, + renderAuthorReports, + renderAuthorCompare, + renderAuthorCollaboration, + refreshAuthorSurface, + bindAuthorWorkspaceEvents, + initializeAuthorWorkspaceRuntime, + createDraftFromCurrentWorld, + createDraftFromBrief, + saveCapabilityAssets, + saveCharacterCard, + saveSceneBlueprint, + bootstrapLongformWorkbench, + saveLongformWorkbench, + savePromiseStateWorkbench, + saveContinuityOverrideWorkbench, + jumpToSelectedCompareChapter, + commentSelectedContinuityChapter, + jumpToSelectedPromiseChapter, + commentSelectedPromise +} = AuthorWorkspaceRuntime; + +var { + refreshCustomerSurface, + initializeCustomerWorkspaceRuntime, +} = CustomerWorkspaceRuntime; + +var { + initializeAgentStudioRuntime, +} = AgentStudioRuntime; + +var { + reviewStatusLabel, + summarizeChecklistEvidence, + applySupportPrefill, + applyGovernanceCasePrefill, + openLearnedWorldDetail, + openLearnedIssueDetail, + selectReviewBacklogItem +} = OpsShared; + +var { + currentOpsNavigationContext, + OPS_REFRESH_SCOPE_ALL, + normalizeOpsRefreshScopes, + syncOpsNavigationContext, + refreshOpsReleaseWorkspace, + refreshOpsAlerts, + refreshOpsSurface, + refreshOpsAccountFlow, + refreshOpsReleaseFlow, + refreshOpsJobsFlow, + refreshOpsLearnedFlow +} = OpsRefreshRuntime; + +var { + renderOpsSurface +} = OpsRenderRuntime; + +var { + submitPromotionDecision, + submitRerankerPromotionDecision, + submitProviderRollout, + runDataIntegrityRepair, + submitAssistedGateConfig, + submitAssistedRerankConfig, + createGovernanceCase, + updateGovernanceCaseStatus, + applyGovernanceRestriction, + releaseGovernanceRestriction, + assignGovernanceCase, + addGovernanceEvidence, + refreshGovernanceAuditExport, + createRuntimeBackup, + restoreRuntimeBackup, + runRecoveryDrill, + requestRuntimeRestore, + approveRuntimeRestore, + revokeRuntimeRestore, + executeRuntimeRestore, + retryAsyncJob, + resumeAsyncJob, + recoverAsyncJobIncidents, + enforceAsyncJobRetention, + runColdStartRecoveryDrill, + exportAsyncJobHandoffBundle, + acknowledgeAsyncJob, + shipRemoteArtifacts, + escalateHandoffSla, + enqueueNotificationRetry, + processNotificationRetry, + runLearnedTraining, + grantOpsSubscription, + changeOpsSubscriptionState, + grantOpsWallet, + debitOpsWallet, + reconcileOpsSubscription, + retryOpsSubscriptionPayment, + replayOpsBillingEvent, + updateSelectedOpsAlertStatus, + openSelectedOpsAlertInvestigation, + followOpsNavigationRecommendation, + runOpsInvestigation, + exportOpsInvestigationTrace, + revokeOpsEntitlement +} = OpsActionsRuntime; + +var { + bindOpsEvents, + initializeOpsRuntime, + submitOpsReviewCapture, + submitOpsPreferenceCapture, + submitOpsRankingCapture +} = OpsRuntime; + +var ReaderRuntimeExports = + (typeof ReaderShellV2 === "object" && ReaderShellV2) + || (typeof ReaderRuntime === "object" && ReaderRuntime) + || {}; + +var { + activeReaderId, + refreshReaderEntitlements, + startReaderCheckout, + retryReaderSubscriptionPayment, + renewReaderSubscription, + cancelReaderSubscription, + grantReaderEntitlement, + renderIntentPrefill, + worldDisplayMeta, + renderWorldGallery, + renderSessionLibrary, + renderSuggestedInputs, + renderRoutePreview, + spotlightPreviewResult, + spotlightChapter, + setTone, + getStorySource, + renderStorybook, + renderLatestStep, + renderStoryFeed, + renderReplay, + updateBundleSummary, + loadExampleBundle, + refreshExamples, + refreshSessionLibrary, + bootstrapWorld, + restoreSession, + deleteSession, + previewRoute, + stepSession, + resetOutput, + bootstrapHealth, + bindReaderEvents, + initializeReaderRuntime, +} = ReaderRuntimeExports; diff --git a/src/narrativeos/web/shell_dom.js b/src/narrativeos/web/shell_dom.js new file mode 100644 index 0000000..c53ef61 --- /dev/null +++ b/src/narrativeos/web/shell_dom.js @@ -0,0 +1,43 @@ +// Shell-scoped DOM registry. + +var ShellDOM = (() => ({ + appShell: DOMShared.query("#app-shell"), + shellStatusBanner: DOMShared.query("#shell-status-banner"), + shellLiveRegion: DOMShared.query("#shell-live-region"), + shellToastStack: DOMShared.query("#shell-toast-stack"), + shellAuthStage: DOMShared.query("#shell-auth-stage"), + shellAuthHeadline: DOMShared.query("#shell-auth-headline"), + shellAuthCopy: DOMShared.query("#shell-auth-copy"), + shellAuthRoleButtons: DOMShared.queryAll("[data-shell-register-role]"), + shellAuthActorId: DOMShared.query("#shell-auth-actor-id"), + shellAuthDisplayName: DOMShared.query("#shell-auth-display-name"), + shellAuthPassword: DOMShared.query("#shell-auth-password"), + shellAuthRegister: DOMShared.query("#shell-auth-register"), + shellAuthLogin: DOMShared.query("#shell-auth-login"), + shellAuthLogout: DOMShared.query("#shell-auth-logout"), + shellAuthRoleSummary: DOMShared.query("#shell-auth-role-summary"), + shellAuthStatus: DOMShared.query("#shell-auth-status"), + authRouteStage: DOMShared.query("#auth-route-stage"), + authRouteContent: DOMShared.query("#auth-route-content"), + shellSessionSummary: DOMShared.query("#shell-session-summary"), + shellSessionCopy: DOMShared.query("#shell-session-copy"), + shellSessionLogout: DOMShared.query("#shell-session-logout"), + shellDebugToggle: DOMShared.query("#shell-debug-toggle"), + shellProductStatus: DOMShared.query("#shell-product-status"), + shellContextCopy: DOMShared.query("#shell-context-copy"), + shellReaderId: DOMShared.query("#shell-reader-id"), + productSubnav: DOMShared.query("#product-subnav"), + productSubnavLabel: DOMShared.query("#product-subnav-label"), + productSubnavDescription: DOMShared.query("#product-subnav-description"), + productSubnavActions: DOMShared.query("#product-subnav-actions"), + modeReader: DOMShared.query("#mode-reader"), + modeAuthor: DOMShared.query("#mode-author"), + modeCustomer: DOMShared.query("#mode-customer"), + modeOps: DOMShared.query("#mode-ops"), + readerShellV2: DOMShared.query("#reader-shell-v2"), + readerShell: DOMShared.query("#reader-shell"), + authorShell: DOMShared.query("#author-shell"), + customerShell: DOMShared.query("#customer-shell"), + opsShell: DOMShared.query("#ops-shell"), + apiStatus: DOMShared.query("#api-status"), +}))(); diff --git a/src/narrativeos/web/shell_runtime.js b/src/narrativeos/web/shell_runtime.js new file mode 100644 index 0000000..7c4ad57 --- /dev/null +++ b/src/narrativeos/web/shell_runtime.js @@ -0,0 +1,726 @@ +// Shell runtime extracted from app.js so the shell owns startup and event binding. + +var ShellRuntime = (() => { + const dom = ShellDOM; + const { + reportUiMessage, + installNonBlockingAlerts, + api, + parseErrorDetail, + describeAuthError, + } = UIShared; + + let shellRuntimeInitialized = false; + const authRouteState = { + lastEmail: "", + verifyStatus: "idle", + verifyMessage: "", + handledVerifyToken: null, + resetStatus: "idle", + resetMessage: "", + }; + + function requireRuntimeFunction(name, fn) { + if (typeof fn !== "function") { + throw new TypeError(`${name} is not a function`); + } + return fn; + } + + function looksLikeEmail(value) { + return String(value || "").includes("@"); + } + + function escapeHtml(value) { + return String(value || "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + function currentAuthPage() { + return shellState.authPage || null; + } + + function currentAuthQuery() { + if (typeof window === "undefined") return new URLSearchParams(); + return new URLSearchParams(window.location.search); + } + + function currentAuthFlow() { + return String(currentAuthQuery().get("flow") || "").trim(); + } + + function rememberAuthEmail(email) { + const normalized = String(email || "").trim(); + if (!normalized) return; + authRouteState.lastEmail = normalized; + if (dom.shellAuthActorId) { + dom.shellAuthActorId.value = normalized; + } + if (ReaderDOM.readerAuthActorId) { + ReaderDOM.readerAuthActorId.value = normalized; + } + } + + function authRouteEmail() { + const queryEmail = String(currentAuthQuery().get("email") || "").trim(); + return queryEmail || authRouteState.lastEmail || dom.shellAuthActorId?.value.trim() || ""; + } + + function authLink(label, href, variant = "ghost-action") { + return `${label}`; + } + + function authRouteCard(title, body, actions = "", extra = "") { + return ` +
+
+

${escapeHtml(title)}

+
+

${escapeHtml(body)}

+ ${extra} + ${actions ? `
${actions}
` : ""} +
+ `; + } + + function setStoredAuthorSession(payload) { + authorState.authorAuthSession = { + accessToken: payload.token?.access_token, + expiresAt: payload.token?.expires_at, + identity: payload.identity, + tokenType: payload.token?.token_type || "bearer", + }; + if (typeof window !== "undefined") { + window.localStorage.setItem("narrativeos_author_auth", JSON.stringify(authorState.authorAuthSession)); + } + } + + async function submitAuthRouteSignup() { + const email = String(dom.authRouteContent?.querySelector("#auth-route-signup-email")?.value || "").trim(); + const password = String(dom.authRouteContent?.querySelector("#auth-route-signup-password")?.value || "").trim(); + const displayName = String(dom.authRouteContent?.querySelector("#auth-route-signup-display-name")?.value || "").trim(); + if (!email || !password) { + reportUiMessage("请先填写邮箱和密码。", "warning"); + return; + } + await api("/v1/auth/register", { + method: "POST", + body: JSON.stringify({ + actor_id: email, + actor_role: "author", + password, + account_id: email, + display_name: displayName || null, + }), + }); + rememberAuthEmail(email); + reportUiMessage("注册已提交。请先检查验证邮件,再回来登录。", "success"); + if (typeof window !== "undefined") { + window.location.assign(`/app/verify-email?email=${encodeURIComponent(email)}`); + } + } + + async function submitAuthRouteLogin() { + const email = String(dom.authRouteContent?.querySelector("#auth-route-login-email")?.value || "").trim(); + const password = String(dom.authRouteContent?.querySelector("#auth-route-login-password")?.value || "").trim(); + if (!email || !password) { + reportUiMessage("请先填写邮箱和密码。", "warning"); + return; + } + const payload = await api("/v1/auth/login", { + method: "POST", + body: JSON.stringify({ + actor_id: email, + password, + }), + }); + rememberAuthEmail(email); + setStoredAuthorSession(payload); + if (typeof window !== "undefined") { + window.location.replace("/app"); + } + } + + async function submitAuthRouteVerificationRequest() { + const email = String(dom.authRouteContent?.querySelector("#auth-route-verify-email-input")?.value || "").trim(); + if (!email) { + reportUiMessage("请先填写邮箱。", "warning"); + return; + } + const payload = await api("/v1/auth/verification/request", { + method: "POST", + body: JSON.stringify({ actor_id: email }), + }); + rememberAuthEmail(email); + authRouteState.verifyStatus = "resent"; + authRouteState.verifyMessage = payload.status === "already_verified" + ? "这个邮箱已经验证完成,现在可以直接登录。" + : "验证邮件已经重新发送,请检查邮箱。"; + renderAuthRouteStage(); + } + + async function submitAuthRouteForgotPassword() { + const email = String(dom.authRouteContent?.querySelector("#auth-route-forgot-email")?.value || "").trim(); + if (!email) { + reportUiMessage("请先填写邮箱。", "warning"); + return; + } + await api("/v1/auth/password-reset/request", { + method: "POST", + body: JSON.stringify({ actor_id: email }), + }); + rememberAuthEmail(email); + authRouteState.resetStatus = "requested"; + authRouteState.resetMessage = "密码重置邮件已经发出,请检查邮箱。"; + renderAuthRouteStage(); + } + + async function submitAuthRouteResetPassword() { + const token = String(currentAuthQuery().get("token") || "").trim(); + const password = String(dom.authRouteContent?.querySelector("#auth-route-reset-password-input")?.value || "").trim(); + if (!token) { + authRouteState.resetStatus = "error"; + authRouteState.resetMessage = "重置链接缺少必要信息,请重新申请一次密码重置。"; + renderAuthRouteStage(); + return; + } + if (!password) { + reportUiMessage("请先输入新密码。", "warning"); + return; + } + await api("/v1/auth/password-reset/confirm", { + method: "POST", + body: JSON.stringify({ token, new_password: password }), + }); + authRouteState.resetStatus = "success"; + authRouteState.resetMessage = "密码已经更新完成,现在可以用新密码登录。"; + renderAuthRouteStage(); + } + + async function resolveVerifyEmailTokenIfPresent() { + const token = String(currentAuthQuery().get("token") || "").trim(); + if (!token || authRouteState.handledVerifyToken === token || currentAuthPage() !== "verify-email") { + return; + } + authRouteState.handledVerifyToken = token; + authRouteState.verifyStatus = "pending"; + authRouteState.verifyMessage = "正在验证你的邮箱,请稍候。"; + renderAuthRouteStage(); + try { + const flow = currentAuthFlow(); + const payload = await api(flow === "email-change" ? "/v1/auth/email-change/confirm" : "/v1/auth/verification/confirm", { + method: "POST", + body: JSON.stringify({ token }), + }); + rememberAuthEmail(payload.identity?.email_address || payload.identity?.account_id || payload.identity?.actor_id || ""); + authRouteState.verifyStatus = "success"; + authRouteState.verifyMessage = flow === "email-change" + ? "新邮箱已经确认完成。旧邮箱现在已经失效,请使用新邮箱重新登录。" + : "邮箱已经验证完成,现在可以登录了。"; + } catch (error) { + authRouteState.verifyStatus = "error"; + authRouteState.verifyMessage = describeAuthError( + error, + currentAuthFlow() === "email-change" + ? "这个邮箱变更链接现在不可用,请回到账户页重新发起一次邮箱迁移。" + : "这个验证链接现在不可用,请重新发送一封验证邮件。" + ); + } + renderAuthRouteStage(); + } + + function bindAuthRouteStageEvents() { + if (!dom.authRouteContent) return; + dom.authRouteContent.querySelector("#auth-route-signup-submit")?.addEventListener("click", async () => { + try { + await submitAuthRouteSignup(); + } catch (error) { + reportUiMessage(describeAuthError(error, "注册暂时失败,请稍后重试。"), "error"); + } + }); + dom.authRouteContent.querySelector("#auth-route-login-submit")?.addEventListener("click", async () => { + try { + await submitAuthRouteLogin(); + } catch (error) { + const detail = parseErrorDetail(error) || {}; + reportUiMessage(describeAuthError(error, "登录暂时失败,请稍后重试。"), detail.code === "auth_email_unverified" ? "warning" : "error"); + } + }); + dom.authRouteContent.querySelector("#auth-route-verify-submit")?.addEventListener("click", async () => { + try { + await submitAuthRouteVerificationRequest(); + } catch (error) { + const detail = parseErrorDetail(error) || {}; + reportUiMessage(describeAuthError(error, "验证邮件暂时发送失败,请稍后重试。"), detail.reason === "verification_resend_cooldown" ? "warning" : "error"); + } + }); + dom.authRouteContent.querySelector("#auth-route-forgot-submit")?.addEventListener("click", async () => { + try { + await submitAuthRouteForgotPassword(); + } catch (error) { + reportUiMessage(describeAuthError(error, "密码重置邮件暂时发送失败,请稍后重试。"), "error"); + } + }); + dom.authRouteContent.querySelector("#auth-route-reset-submit")?.addEventListener("click", async () => { + try { + await submitAuthRouteResetPassword(); + } catch (error) { + reportUiMessage(describeAuthError(error, "重置密码暂时失败,请稍后重试。"), "error"); + } + }); + } + + function renderAuthRouteStage() { + if (!dom.authRouteContent) return; + const page = currentAuthPage(); + const email = escapeHtml(authRouteEmail()); + const tokenPresent = Boolean(currentAuthQuery().get("token")); + const flow = currentAuthFlow(); + let html = ""; + if (page === "signup") { + html = authRouteCard( + "创建邮箱账号", + "注册后系统会先发送验证邮件。验证完成后,再回到登录页进入你的工作区。", + `${authLink("去登录", "/app/login")} ${authLink("验证邮箱帮助", "/app/verify-email")}`, + ` +
+ + + + + + +
+
+ +
+ ` + ); + } else if (page === "login") { + html = authRouteCard( + "邮箱登录", + "如果你的邮箱还没验证,系统会直接告诉你下一步该做什么,不会再把邮件问题显示成服务器错误。", + `${authLink("去注册", "/app/signup")} ${authLink("忘记密码", "/app/forgot-password")} ${authLink("重新发送验证邮件", "/app/verify-email")}`, + ` +
+ + + + +
+
+ +
+ ` + ); + } else if (page === "verify-email") { + const isEmailChangeFlow = flow === "email-change"; + const message = authRouteState.verifyMessage || ( + tokenPresent + ? (isEmailChangeFlow ? "正在确认你的新邮箱,请稍候。" : "正在等待验证结果。") + : (isEmailChangeFlow ? "这个页面用于确认账户邮箱迁移,请从发往新邮箱的确认链接进入。" : "如果你还没收到验证邮件,可以在这里重新发送。") + ); + html = authRouteCard( + authRouteState.verifyStatus === "success" ? (isEmailChangeFlow ? "新邮箱已确认" : "邮箱已验证") : (isEmailChangeFlow ? "确认新邮箱" : "验证邮箱"), + message, + authRouteState.verifyStatus === "success" + ? `${authLink("去登录", "/app/login", "primary-action")}` + : `${authLink("去登录", "/app/login")} ${authLink("去注册", "/app/signup")}`, + authRouteState.verifyStatus === "success" || isEmailChangeFlow + ? "" + : ` +
+ + +
+
+ +
+ ` + ); + } else if (page === "forgot-password") { + html = authRouteCard( + "忘记密码", + authRouteState.resetStatus === "requested" ? authRouteState.resetMessage : "输入你的邮箱,系统会发送一封密码重置邮件。", + `${authLink("去登录", "/app/login")} ${authLink("重新发送验证邮件", "/app/verify-email")}`, + ` +
+ + +
+
+ +
+ ` + ); + } else if (page === "reset-password") { + const body = authRouteState.resetMessage || (tokenPresent ? "输入新密码,完成这次密码重置。" : "这个页面缺少重置链接信息,请重新申请一次密码重置。"); + html = authRouteCard( + authRouteState.resetStatus === "success" ? "密码已重置" : "重置密码", + body, + authRouteState.resetStatus === "success" + ? `${authLink("去登录", "/app/login", "primary-action")}` + : `${authLink("重新申请密码重置", "/app/forgot-password")} ${authLink("去登录", "/app/login")}`, + authRouteState.resetStatus === "success" + ? "" + : ` +
+ + +
+
+ +
+ ` + ); + } + dom.authRouteContent.innerHTML = html; + bindAuthRouteStageEvents(); + } + + function syncShellAuthFormIntoAuthorAuthForm() { + if (AuthorDOM.authorAuthActorId) { + AuthorDOM.authorAuthActorId.value = dom.shellAuthActorId?.value.trim() || ""; + } + if (AuthorDOM.authorAuthDisplayName) { + AuthorDOM.authorAuthDisplayName.value = dom.shellAuthDisplayName?.value.trim() || ""; + } + if (AuthorDOM.authorAuthPassword) { + AuthorDOM.authorAuthPassword.value = dom.shellAuthPassword?.value || ""; + } + if (AuthorDOM.authorAuthRole) { + AuthorDOM.authorAuthRole.value = "author"; + } + if (AuthorDOM.authorAccountId && !AuthorDOM.authorAccountId.value.trim()) { + AuthorDOM.authorAccountId.value = dom.shellAuthActorId?.value.trim() || ""; + } + } + + function seedReviewerWorkbenchFromSession() { + const actorId = String(authorState.authorAuthSession?.identity?.actor_id || "").trim(); + const actorRole = String(authorState.authorAuthSession?.identity?.actor_role || "").trim(); + if (!actorId || !["reviewer", "ops", "admin"].includes(actorRole)) return; + if (AuthorDOM.authorInboxReviewerId && !AuthorDOM.authorInboxReviewerId.value.trim()) { + AuthorDOM.authorInboxReviewerId.value = actorId; + } + if (AuthorDOM.authorApprovalReviewer && !AuthorDOM.authorApprovalReviewer.value.trim()) { + AuthorDOM.authorApprovalReviewer.value = actorId; + } + } + + function shouldResumeStartupOpsRoute(actorRole = "") { + return ["reviewer", "ops", "admin"].includes(String(actorRole || "").trim()) && shellState.startupRouteProduct === "ops"; + } + + async function bootstrapShellAuthFromCookie() { + if (authorState.authorAuthSession?.identity) { + return true; + } + const hasBootstrapCandidate = Boolean( + authorState.authorAuthSession?.accessToken + || authorState.authorAuthSession?.cookieBacked + || authorState.authorAuthSession?.identity + || readerState.readerAuthSession?.accessToken + || readerState.readerAuthSession?.cookieBacked + || readerState.readerAuthSession?.identity + ); + if (!hasBootstrapCandidate) { + return false; + } + try { + const payload = await api("/v1/auth/me"); + if (!payload?.identity) { + return false; + } + authorState.authorAuthSession = { + accessToken: null, + expiresAt: payload.identity?.expires_at || null, + identity: payload.identity, + tokenType: "bearer", + cookieBacked: true, + }; + if (typeof persistAuthorAuthSession === "function") { + persistAuthorAuthSession(); + } + return true; + } catch (_error) { + return false; + } + } + + async function finalizeShellAuthSuccess() { + const actorRole = String(authorState.authorAuthSession?.identity?.actor_role || "").trim(); + seedReviewerWorkbenchFromSession(); + if (actorRole === "author") { + shellState.activeProduct = "author"; + if (!shellState.authorWorkspace || shellState.authorWorkspace === "settings") { + shellState.authorWorkspace = "overview"; + } + await refreshAuthorSurface(); + } else if (actorRole === "customer") { + shellState.activeProduct = "customer"; + shellState.customerWorkspace = "overview"; + await refreshCustomerSurface(); + } else if (["reviewer", "ops", "admin"].includes(actorRole)) { + if (shouldResumeStartupOpsRoute(actorRole)) { + shellState.activeProduct = "ops"; + if (shellState.startupRouteWorkspace) { + shellState.opsWorkspace = shellState.startupRouteWorkspace; + } + await refreshOpsReleaseFlow(); + } else { + shellState.activeProduct = "author"; + shellState.authorWorkspace = "settings"; + await refreshAuthorSurface(); + } + } else { + shellState.activeProduct = "reader"; + shellState.readerWorkspace = "landing"; + } + if (dom.shellAuthPassword) { + dom.shellAuthPassword.value = ""; + } + syncProductMode(); + updateStatus(); + } + + async function registerFromShellAuth() { + const actorId = String(dom.shellAuthActorId?.value || "").trim(); + if (looksLikeEmail(actorId)) { + await api("/v1/auth/register", { + method: "POST", + body: JSON.stringify({ + actor_id: actorId, + actor_role: "author", + password: String(dom.shellAuthPassword?.value || ""), + account_id: actorId, + display_name: String(dom.shellAuthDisplayName?.value || "").trim() || null, + }), + }); + rememberAuthEmail(actorId); + reportUiMessage("注册已提交。请先检查验证邮件,再回来登录。", "success"); + if (typeof window !== "undefined") { + window.location.assign(`/app/verify-email?email=${encodeURIComponent(actorId)}`); + } + return; + } + syncShellAuthFormIntoAuthorAuthForm(); + await registerAuthorAuthIdentity(); + await finalizeShellAuthSuccess(); + } + + async function loginFromShellAuth() { + syncShellAuthFormIntoAuthorAuthForm(); + await loginAuthorAuthIdentity(); + await finalizeShellAuthSuccess(); + } + + async function logoutFromShellAuth() { + await logoutAuthorAuthIdentity(); + if (dom.shellAuthPassword) { + dom.shellAuthPassword.value = ""; + } + syncProductMode(); + updateStatus(); + } + + async function resolveAdminViewBridgeIfPresent() { + if (!shellState.adminViewBridgeToken) { + shellState.adminViewEnabled = ["reviewer", "ops", "admin"].includes( + String(authorState.authorAuthSession?.identity?.actor_role || "").trim() + ); + return; + } + try { + const payload = await api("/v1/auth/admin-view-bridge/resolve", { + method: "POST", + body: JSON.stringify({ token: shellState.adminViewBridgeToken }), + }); + shellState.adminViewEnabled = Boolean(payload.authorized); + if (typeof window !== "undefined") { + window.sessionStorage.setItem("narrativeos_admin_view_bridge", shellState.adminViewBridgeToken); + } + const context = payload.context || {}; + if (shellState.activeProduct === "ops" && typeof syncOpsNavigationContext === "function") { + syncOpsNavigationContext( + { + account_id: context.account_id, + world_id: context.world_id, + world_version_id: context.world_version_id, + case_id: context.case_id, + alert_id: context.alert_id, + }, + { preserveExisting: false } + ); + if (context.workspace) { + shellState.opsWorkspace = context.workspace; + } + } + } catch (error) { + shellState.adminViewEnabled = false; + if (typeof window !== "undefined") { + window.sessionStorage.removeItem("narrativeos_admin_view_bridge"); + } + if (shellState.activeProduct === "ops") { + shellState.activeProduct = authorState.authorAuthSession?.identity ? "author" : "reader"; + } + const detail = parseErrorDetail(error); + if (detail?.code === "admin_view_bridge_forbidden") { + reportUiMessage("当前登录身份没有管理员视图权限,需要 reviewer / ops / admin 账号。", "warning"); + } else { + reportUiMessage(`管理员视图校验失败:${error.message}`, "error"); + } + } finally { + if (!shellState.adminViewEnabled) { + shellState.adminViewBridgeToken = null; + } + } + } + + async function initializeShellRuntime() { + if (shellRuntimeInitialized) return; + shellRuntimeInitialized = true; + + if (typeof window !== "undefined") { + window.__bootMarker = "before-bindings"; + } + + try { + dom.modeReader?.addEventListener("click", () => { + shellState.activeProduct = "reader"; + syncProductMode(); + }); + dom.modeAuthor?.addEventListener("click", async () => { + shellState.activeProduct = "author"; + syncProductMode(); + try { + await refreshAuthorSurface(); + } catch (error) { + reportUiMessage(`作者工作台刷新失败:${error.message}`, "error"); + } + }); + dom.modeCustomer?.addEventListener("click", async () => { + shellState.activeProduct = "customer"; + syncProductMode(); + try { + await refreshCustomerSurface(); + } catch (error) { + reportUiMessage(`客户工作台刷新失败:${error.message}`, "error"); + } + }); + dom.modeOps?.addEventListener("click", async () => { + shellState.activeProduct = "ops"; + syncProductMode(); + try { + await refreshOpsReleaseFlow(); + } catch (error) { + reportUiMessage(`运营工作台刷新失败:${error.message}`, "error"); + } + }); + dom.shellDebugToggle?.addEventListener("click", () => toggleDebugMode()); + dom.shellAuthRegister?.addEventListener("click", async () => { + try { + await registerFromShellAuth(); + } catch (error) { + reportUiMessage(describeAuthError(error, "注册暂时失败,请稍后重试。"), "error"); + } + }); + dom.shellAuthLogin?.addEventListener("click", async () => { + try { + await loginFromShellAuth(); + } catch (error) { + const detail = parseErrorDetail(error) || {}; + reportUiMessage(describeAuthError(error, "登录暂时失败,请稍后重试。"), detail.code === "auth_email_unverified" ? "warning" : "error"); + } + }); + dom.shellAuthLogout?.addEventListener("click", async () => { + try { + await logoutFromShellAuth(); + } catch (error) { + reportUiMessage(`统一退出失败:${error.message}`, "error"); + } + }); + dom.shellSessionLogout?.addEventListener("click", async () => { + try { + await logoutFromShellAuth(); + } catch (error) { + reportUiMessage(`统一退出失败:${error.message}`, "error"); + } + }); + } catch (error) { + console.error("listener bootstrap failed", error); + } + + if (typeof window !== "undefined") { + window.__bootMarker = "after-bindings"; + } + + hydrateShellRoute(); + if (typeof restoreAuthorAuthSession === "function") { + restoreAuthorAuthSession(); + } + await bootstrapShellAuthFromCookie(); + installNonBlockingAlerts(); + installScrollWorkspaceBridge(); + try { + requireRuntimeFunction("initializeReaderRuntime", initializeReaderRuntime)(); + requireRuntimeFunction("initializeAuthorWorkspaceRuntime", initializeAuthorWorkspaceRuntime)(); + requireRuntimeFunction("initializeAgentStudioRuntime", initializeAgentStudioRuntime)(); + requireRuntimeFunction("initializeCustomerWorkspaceRuntime", initializeCustomerWorkspaceRuntime)(); + requireRuntimeFunction("initializeOpsRuntime", initializeOpsRuntime)(); + } catch (error) { + console.error("runtime bootstrap failed", error); + reportUiMessage(`前端启动失败:${error.message}`, "error"); + return; + } + try { + await hydrateAuthorAuthSession(); + } catch (error) { + console.error("author auth bootstrap failed", error); + } + try { + await resolveAdminViewBridgeIfPresent(); + } catch (error) { + console.error("admin view bridge bootstrap failed", error); + } + syncProductMode(); + syncViewMode(); + renderAuthRouteStage(); + if (shellState.authPage === "verify-email") { + resolveVerifyEmailTokenIfPresent().catch((error) => { + reportUiMessage(describeAuthError(error, "邮箱验证暂时失败,请稍后重试。"), "error"); + }); + } + if (shellState.authPage) { + updateStatus(); + return; + } + if (shellState.activeProduct === "author") { + refreshAuthorSurface().catch((error) => { + reportUiMessage(`作者工作台初始化失败:${error.message}`, "error"); + }); + } + if (shellState.activeProduct === "customer") { + refreshCustomerSurface().catch((error) => { + reportUiMessage(`客户工作台初始化失败:${error.message}`, "error"); + }); + } + if (shellState.activeProduct === "ops") { + refreshOpsReleaseFlow().catch((error) => { + reportUiMessage(`运营工作台初始化失败:${error.message}`, "error"); + }); + } + + if (typeof window !== "undefined") { + window.__bootMarker = "after-init"; + } + } + + return { initializeShellRuntime }; +})(); + +ShellRuntime.initializeShellRuntime(); diff --git a/src/narrativeos/web/shell_status_runtime.js b/src/narrativeos/web/shell_status_runtime.js new file mode 100644 index 0000000..9537f08 --- /dev/null +++ b/src/narrativeos/web/shell_status_runtime.js @@ -0,0 +1,464 @@ +// Shell status orchestration extracted from app.js. + +var ShellStatusRuntime = (() => { + const shellDom = ShellDOM; + const readerDom = ReaderDOM; + const { reportUiMessage, clearNode, createListCard } = UIShared; + const { tierLabel, accessReasonLabel, worldUnlockLabel } = ReaderAccessors; + const REVIEWER_ROLES = new Set(["reviewer", "ops", "admin"]); + const CUSTOMER_ROLES = new Set(["customer", "reviewer", "ops", "admin"]); + const ROLE_PROFILES = { + guest: { + label: "访客", + surfaces: ["阅读", "创作"], + summary: "可以先浏览世界与创作台,登录后再把权限和身份固定下来。", + nextStep: "先登录一个账号,系统会按后台分配的权限打开对应能力。", + }, + reader: { + label: "阅读账号", + surfaces: ["阅读"], + summary: "当前账号只有阅读能力,不会展开创作和审阅收件箱。", + nextStep: "如需创作或审阅,请联系管理员调整账号权限。", + }, + author: { + label: "普通用户", + surfaces: ["阅读", "创作"], + summary: "可以继续阅读、创建草稿、修改人物和场景,但不会看到可审阅内容。", + nextStep: "推荐进入创作台继续起稿或修稿。", + }, + customer: { + label: "客户账号", + surfaces: ["客户"], + summary: "当前账号进入客户工作台,查看套餐、限额、生命周期和后续商业报告。", + nextStep: "推荐先确认当前 plan、限额和续费状态。", + }, + reviewer: { + label: "审阅者", + surfaces: ["阅读", "创作", "客户", "审阅"], + summary: "会展开 reviewer inbox、待审稿件与快速审批动作。", + nextStep: "推荐直接进入账户协作查看可审阅内容。", + }, + ops: { + label: "运营审阅", + surfaces: ["阅读", "创作", "客户", "审阅", "运营"], + summary: "除了审阅内容,还可以切到运营视图处理治理、发布和告警。", + nextStep: "可先查看 reviewer inbox,再根据需要切运营台。", + }, + admin: { + label: "管理员", + surfaces: ["阅读", "创作", "客户", "审阅", "运营"], + summary: "拥有完整的审阅和运营视图,会同时展开 reviewer 内容与 ops 入口。", + nextStep: "先确认待审内容,再进入运营台处理跨域动作。", + }, + }; + + function appAccessAuthenticated() { + return Boolean(authorState.authorAuthSession?.identity || authorState.authorAuthSession?.accessToken); + } + + function authPageActive() { + return Boolean(shellState.authPage); + } + + function currentShellIdentity() { + return authorState.authorAuthSession?.identity || null; + } + + function currentShellRole() { + return String(currentShellIdentity()?.actor_role || "").trim() || "guest"; + } + + function currentShellRoleProfile() { + return ROLE_PROFILES[currentShellRole()] || ROLE_PROFILES.guest; + } + + function reviewerContentEnabled() { + return REVIEWER_ROLES.has(currentShellRole()); + } + + function customerViewEnabled() { + return CUSTOMER_ROLES.has(currentShellRole()); + } + + function renderShellSessionSummary() { + const identity = currentShellIdentity(); + const profile = currentShellRoleProfile(); + shellDom.shellSessionSummary?.classList.toggle("is-hidden", !identity); + if (shellDom.shellSessionCopy) { + shellDom.shellSessionCopy.textContent = identity + ? `${identity.display_name || identity.actor_id || identity.account_id || "-"} · ${profile.label} · 可进入 ${profile.surfaces.join(" / ")}` + : "登录后,这里会显示当前账号与权限。"; + } + } + + function hasPrivilegedOpsIdentity() { + return reviewerContentEnabled(); + } + + function opsViewEnabled() { + return Boolean(shellState.debug || shellState.adminViewEnabled || hasPrivilegedOpsIdentity()); + } + + function renderShellAuthStage() { + clearNode(shellDom.shellAuthStatus); + const identity = currentShellIdentity(); + const role = currentShellRole(); + const profile = currentShellRoleProfile(); + const displayName = identity?.display_name || identity?.actor_id || identity?.account_id || "未登录"; + const actorId = identity?.actor_id || identity?.account_id || ""; + + shellDom.appShell.dataset.role = role; + shellDom.appShell.dataset.reviewer = reviewerContentEnabled() ? "on" : "off"; + shellDom.appShell.dataset.authenticated = appAccessAuthenticated() ? "on" : "off"; + shellDom.shellAuthStage?.classList.toggle("is-authenticated", Boolean(identity)); + + if (shellDom.shellAuthHeadline) { + if (!identity && shellState.authPage === "signup") { + shellDom.shellAuthHeadline.textContent = "用邮箱注册 NarrativeOS。"; + } else if (!identity && shellState.authPage === "login") { + shellDom.shellAuthHeadline.textContent = "用邮箱登录 NarrativeOS。"; + } else { + shellDom.shellAuthHeadline.textContent = identity + ? `${displayName},当前是${profile.label}工作区。` + : "登录后,进入你的工作区。"; + } + } + if (shellDom.shellAuthCopy) { + if (!identity && shellState.authPage === "signup") { + shellDom.shellAuthCopy.textContent = "邮箱注册会先发送验证邮件。验证完成后,你就可以正常登录、继续阅读和使用后续账号能力。"; + } else if (!identity && shellState.authPage === "login") { + shellDom.shellAuthCopy.textContent = "如果你的邮箱还没验证,系统会明确提示你先完成验证,并提供重新发送验证邮件的入口。"; + } else { + shellDom.shellAuthCopy.textContent = identity + ? `${profile.summary} ${profile.nextStep}` + : "新注册账号默认是普通用户。审阅权限由管理员在后台分配,拥有权限的账号登录后会自动看到可审阅内容。"; + } + } + if (shellDom.shellAuthRoleSummary) { + shellDom.shellAuthRoleSummary.textContent = identity + ? `当前账号:${profile.label}。可进入 ${profile.surfaces.join(" / ")}。` + : "注册会默认创建普通用户账号;如果管理员后续赋予审阅权限,登录时会自动进入对应能力。"; + } + if (shellDom.shellAuthActorId && !shellDom.shellAuthActorId.value.trim() && actorId) { + shellDom.shellAuthActorId.value = actorId; + } + if (shellDom.shellAuthDisplayName && !shellDom.shellAuthDisplayName.value.trim() && identity?.display_name) { + shellDom.shellAuthDisplayName.value = identity.display_name; + } + + shellDom.shellAuthStatus.appendChild( + createListCard({ + title: identity ? `${displayName} · 已登录` : "尚未登录", + score: identity ? profile.label : "等待身份", + body: + `账号 ${actorId || "-"}\n` + + `角色 ${profile.label}\n` + + `可进入 ${(profile.surfaces || []).join(" / ") || "-"}\n` + + `审阅内容 ${reviewerContentEnabled() ? "已开启" : "未开启"}\n` + + `${identity ? `下一步 ${profile.nextStep}` : "下一步 填写账号和密码后登录,或直接注册一个新账号。"}` + }) + ); + renderShellSessionSummary(); + } + + function syncViewMode() { + shellDom.appShell.dataset.view = readerState.activeView; + if (readerState.activeView !== "backstage") { + shellState.lastReaderView = readerState.activeView; + } + readerDom.viewExperience?.classList.toggle("is-active", readerState.activeView === "experience"); + readerDom.viewStorybook?.classList.toggle("is-active", readerState.activeView === "storybook"); + readerDom.viewBackstage?.classList.toggle("is-active", readerState.activeView === "backstage"); + readerDom.experienceView.classList.toggle("is-hidden", readerState.activeView !== "experience"); + readerDom.storybookView.classList.toggle("is-hidden", readerState.activeView !== "storybook"); + readerDom.backstageView.classList.toggle("is-hidden", readerState.activeView !== "backstage"); + if (shellState.activeProduct === "reader") { + RouteSyncRuntime.syncShellRoute(); + } + } + + function syncProductMode() { + WorkspaceLayoutRuntime.initializeGuidedWorkspaces(); + const authenticated = appAccessAuthenticated(); + const routeAuthPage = authPageActive(); + if (!opsViewEnabled() && shellState.activeProduct === "ops") { + shellState.activeProduct = "reader"; + shellState.opsWorkspace = "dashboard"; + } + if (!customerViewEnabled() && shellState.activeProduct === "customer" && authenticated) { + shellState.activeProduct = reviewerContentEnabled() ? "author" : "reader"; + shellState.customerWorkspace = "overview"; + } + if (currentShellRole() === "customer" && shellState.activeProduct === "author") { + shellState.activeProduct = "customer"; + } + if (currentShellRole() === "customer" && shellState.activeProduct === "ops") { + shellState.activeProduct = "customer"; + shellState.opsWorkspace = "dashboard"; + } + if (!authenticated) { + if (!["reader", "author"].includes(shellState.activeProduct)) { + shellState.activeProduct = "reader"; + } + const hasActiveReaderFlow = Boolean( + readerState.sessionId || + shellState.pendingSessionId || + readerState.activeAuthoredWorkPreview || + readerState.pendingCheckoutStatus + ); + if (shellState.activeProduct === "reader" && !hasActiveReaderFlow) { + shellState.readerWorkspace = "landing"; + } + } + shellDom.appShell.dataset.product = shellState.activeProduct; + shellDom.appShell.dataset.authPage = routeAuthPage ? "on" : "off"; + shellDom.appShell.dataset.debug = shellState.debug ? "on" : "off"; + shellDom.appShell.dataset.adminView = opsViewEnabled() ? "on" : "off"; + shellDom.appShell.dataset.reviewer = reviewerContentEnabled() ? "on" : "off"; + shellDom.appShell.dataset.authenticated = authenticated ? "on" : "off"; + shellDom.appShell.dataset.readerWorkspace = shellState.readerWorkspace || ""; + shellDom.appShell.dataset.authorWorkspace = shellState.authorWorkspace || ""; + shellDom.appShell.dataset.customerWorkspace = shellState.customerWorkspace || ""; + shellDom.appShell.dataset.opsWorkspace = shellState.opsWorkspace || ""; + shellDom.modeReader.classList.toggle("is-active", shellState.activeProduct === "reader"); + shellDom.modeAuthor.classList.toggle("is-active", shellState.activeProduct === "author"); + shellDom.modeCustomer?.classList.toggle("is-active", shellState.activeProduct === "customer"); + shellDom.modeOps.classList.toggle("is-active", shellState.activeProduct === "ops"); + shellDom.modeReader?.classList.toggle("is-hidden", routeAuthPage); + shellDom.modeAuthor?.classList.toggle("is-hidden", routeAuthPage || currentShellRole() === "customer"); + shellDom.modeCustomer?.classList.toggle("is-hidden", routeAuthPage || !customerViewEnabled()); + shellDom.modeOps?.classList.toggle("is-hidden", routeAuthPage || !opsViewEnabled()); + const readerLandingActive = shellState.activeProduct === "reader" && shellState.readerWorkspace !== "read"; + const readerReadActive = shellState.activeProduct === "reader" && shellState.readerWorkspace === "read"; + readerDom.readerLanding?.classList.toggle("is-hidden", !readerLandingActive); + readerDom.featuredWorld?.classList.toggle("is-hidden", shellState.activeProduct !== "reader"); + shellDom.readerShellV2?.classList.toggle("is-hidden", !readerReadActive); + shellDom.readerShell.classList.toggle("is-hidden", !readerReadActive); + shellDom.authorShell.classList.toggle("is-hidden", shellState.activeProduct !== "author"); + shellDom.customerShell.classList.toggle("is-hidden", shellState.activeProduct !== "customer"); + shellDom.opsShell.classList.toggle("is-hidden", shellState.activeProduct !== "ops"); + shellDom.shellAuthStage?.classList.toggle("is-hidden", routeAuthPage || readerReadActive); + shellDom.authRouteStage?.classList.toggle("is-hidden", !routeAuthPage); + document.querySelectorAll(".internal-only").forEach((node) => { + node.classList.toggle("is-hidden", !shellState.debug); + }); + if (readerDom.readerDebugTools) { + readerDom.readerDebugTools.classList.toggle("is-hidden", !shellState.debug); + } + if (readerDom.readerIdInput) { + readerDom.readerIdInput.classList.toggle("is-hidden", !shellState.debug); + readerDom.readerIdInput.previousElementSibling?.classList.toggle("is-hidden", !shellState.debug); + } + readerDom.readerEntitlementList?.classList.toggle("is-hidden", !shellState.debug && !readerState.readerEntitlements.length); + readerDom.readerMembershipOffers?.classList.toggle( + "is-hidden", + !shellState.debug && !readerState.readerCheckoutSession && !readerState.sessionPaywall?.quote && !readerState.latestStep?.paywall?.quote + ); + readerDom.readerCheckoutStatus?.classList.toggle("is-hidden", !shellState.debug && !readerState.readerCheckoutSession); + if (shellDom.shellDebugToggle) { + shellDom.shellDebugToggle.classList.toggle("is-hidden", !shellState.debug); + shellDom.shellDebugToggle.classList.toggle("is-active", shellState.debug); + shellDom.shellDebugToggle.setAttribute("aria-pressed", shellState.debug ? "true" : "false"); + shellDom.shellDebugToggle.textContent = shellState.debug ? "内部模式已开启" : "内部模式"; + } + if (shellDom.shellProductStatus) { + shellDom.shellProductStatus.textContent = !authenticated + ? "未登录" + : shellState.activeProduct === "reader" + ? "阅读" + : shellState.activeProduct === "author" + ? "创作" + : shellState.activeProduct === "customer" + ? "客户" + : "运营"; + } + if (shellDom.shellContextCopy) { + shellDom.shellContextCopy.textContent = + !authenticated + ? shellState.activeProduct === "author" + ? "你可以先浏览创作台和填写 Brief;需要固定身份、邮箱能力或协作能力时,再登录账号。" + : "先注册或登录,再按账号权限进入阅读、创作或审阅工作区。" + : shellState.activeProduct === "reader" + ? "先进入书架,再开始一段故事;需要时再切换阅读视图。" + : shellState.activeProduct === "author" + ? "创作台会按当前阶段组织功能,只展示此刻需要的能力。" + : shellState.activeProduct === "customer" + ? "客户工作台聚焦套餐、限额、生命周期与后续商业报告,不暴露运营原始面板。" + : "运营台仅供内部人员使用,用于审核、发布、治理与排查。"; + } + renderShellAuthStage(); + WorkspaceLayoutRuntime.syncWorkspaceStacks(); + WorkspaceLayoutRuntime.renderProductSubnav(); + syncViewMode(); + RouteSyncRuntime.syncShellRoute(); + } + + function updateStatus() { + if (readerState.activeAuthoredWorkPreview) { + const activeWork = readerState.activeAuthoredWorkPreview; + const chapterCount = Number(activeWork.chapter_count || (activeWork.chapters || []).length || 0); + const targetCount = Number(activeWork.target_chapter_count || 0); + readerDom.worldStatus.textContent = "已加载"; + readerDom.sessionStatus.textContent = "作品阅读"; + readerDom.turnStatus.textContent = chapterCount ? String(chapterCount) : "-"; + if (shellDom.shellReaderId) { + shellDom.shellReaderId.textContent = + authorState.authorAuthSession?.identity?.display_name || + authorState.authorAuthSession?.identity?.account_id || + readerState.readerAuthSession?.identity?.display_name || + readerState.readerAuthSession?.identity?.account_id || + readerState.readerId || + "游客"; + } + readerDom.worldVersionStatus.textContent = activeWork.world_version_id || "-"; + readerDom.accessTierStatus.textContent = "作者作品"; + readerDom.quoteStatus.textContent = "无需解锁"; + readerDom.worldId.textContent = "作者作品"; + readerDom.sessionId.textContent = `章节 ${chapterCount}/${targetCount || "-"}`; + readerDom.previewRoute.disabled = true; + readerDom.stepSession.disabled = true; + readerDom.factCount.textContent = "0"; + readerDom.promiseCount.textContent = "0"; + readerDom.tensionValue.textContent = "0.00"; + readerDom.sceneWindow.textContent = "-"; + if (readerDom.readerWorldUnlockStatus) { + readerDom.readerWorldUnlockStatus.textContent = "作者自有"; + } + if (readerDom.readerEntitlementReason) { + readerDom.readerEntitlementReason.textContent = "无需解锁"; + } + if (readerDom.readerAccessNote) { + readerDom.readerAccessNote.textContent = "当前是你自己的作品只读预览。若要继续生成、改写或修稿,请回创作台处理。"; + } + if (readerDom.readerComposerHint) { + readerDom.readerComposerHint.textContent = "这是你的作品只读预览。这里不会推进读者会话,返回创作台后再继续生成或编辑。"; + } + if (readerDom.readerLandingSummary) { + const authoredCount = Number(readerState.authoredWorkLibrary?.length || 0); + const activeReader = + authorState.authorAuthSession?.identity?.display_name || + authorState.authorAuthSession?.identity?.account_id || + readerState.readerAuthSession?.identity?.display_name || + readerState.readerAuthSession?.identity?.account_id || + readerState.readerId || + "游客"; + readerDom.readerLandingSummary.textContent = `${activeWork.title || "我的作品"} · ${activeReader} · 有 ${authoredCount} 部作品可读 · 当前作品已展开`; + } + return; + } + readerDom.worldStatus.textContent = readerState.worldId ? "已加载" : "未加载"; + readerDom.sessionStatus.textContent = readerState.sessionId ? "运行中" : "未创建"; + readerDom.turnStatus.textContent = readerState.currentState ? String(readerState.currentState.turn_index) : "-"; + if (shellDom.shellReaderId) { + shellDom.shellReaderId.textContent = + readerState.readerAuthSession?.identity?.display_name || + readerState.readerAuthSession?.identity?.account_id || + readerState.readerId || + "游客"; + } + readerDom.worldVersionStatus.textContent = readerState.worldVersionId || "-"; + const activePaywall = readerState.latestStep?.paywall || readerState.sessionPaywall || {}; + const activeContinuityContract = readerState.continuityContract || {}; + const creditEntitlement = readerState.readerEntitlements.find((item) => item.entitlement_type === "credits" && item.status === "active"); + readerDom.accessTierStatus.textContent = activePaywall.access_tier || "试读"; + readerDom.quoteStatus.textContent = activePaywall.quote ? `¥${Number(activePaywall.quote).toFixed(2)}` : "¥0.00"; + readerDom.worldId.textContent = readerState.sessionId ? "已经开始" : "尚未启程"; + readerDom.sessionId.textContent = readerState.currentBundle + ? (readerState.currentBundle.world_bible.creator_controls?.theme_targets || readerState.currentBundle.world_bible.themes || []) + .slice(0, 3) + .join(" / ") || "未设定" + : "-"; + const hasPlayerInput = Boolean(readerDom.playerInput?.value.trim()); + readerDom.previewRoute.disabled = !readerState.currentState || !readerState.currentBundle || !hasPlayerInput; + readerDom.stepSession.disabled = !readerState.sessionId || !hasPlayerInput; + + if (readerState.currentState) { + readerDom.factCount.textContent = String(readerState.currentState.world_facts.length); + readerDom.promiseCount.textContent = String(readerState.currentState.open_promises.length); + readerDom.tensionValue.textContent = Number(readerState.currentState.tension).toFixed(2); + readerDom.sceneWindow.textContent = + readerState.currentState.recent_scene_functions.length > 0 + ? readerState.currentState.recent_scene_functions.join(" / ") + : "-"; + } else { + readerDom.factCount.textContent = "0"; + readerDom.promiseCount.textContent = "0"; + readerDom.tensionValue.textContent = "0.00"; + readerDom.sceneWindow.textContent = "-"; + } + if (readerDom.readerCreditBalance) { + readerDom.readerCreditBalance.textContent = creditEntitlement ? String(Number(creditEntitlement.balance || 0).toFixed(0)) : "-"; + } + if (readerDom.readerWorldUnlockStatus) { + readerDom.readerWorldUnlockStatus.textContent = worldUnlockLabel(activePaywall); + } + if (readerDom.readerEntitlementReason) { + readerDom.readerEntitlementReason.textContent = accessReasonLabel(activePaywall.reason); + } + if (readerDom.readerAccessNote) { + if (activeContinuityContract.status === "quality_guard_failed") { + readerDom.readerAccessNote.textContent = activeContinuityContract.message || "当前章节未入库,但阅读位置已保留,可以直接重试当前章。"; + } else if (shellState.debug) { + readerDom.readerAccessNote.textContent = "调试模式已开启:你可以刷新权益、授予测试额度或直接发起 checkout。"; + } else if (activePaywall.quote) { + readerDom.readerAccessNote.textContent = `当前继续价格为 ¥${Number(activePaywall.quote).toFixed(2)};需要时可直接在阅读流程中解锁。`; + } else { + readerDom.readerAccessNote.textContent = "默认只展示当前访问状态;真正需要解锁时,会在阅读流程中直接提示。"; + } + } + readerDom.readerMembershipOffers?.classList.toggle( + "is-hidden", + !shellState.debug && !readerState.readerCheckoutSession && !activePaywall.quote + ); + readerDom.readerCheckoutStatus?.classList.toggle("is-hidden", !shellState.debug && !readerState.readerCheckoutSession); + if (readerDom.readerComposerHint) { + if (!readerState.currentBundle) { + readerDom.readerComposerHint.textContent = "先从书架挑一个世界,系统才能准备好这一段命运。"; + } else if (activeContinuityContract.status === "quality_guard_failed") { + readerDom.readerComposerHint.textContent = activeContinuityContract.message || "本章未入库,但当前 session、视图和上一章内容都已保留;可直接重试当前章。"; + } else if (activePaywall.required) { + const tierText = activePaywall.required_display_name || tierLabel(activePaywall.tier_id) || "更高访问权限"; + readerDom.readerComposerHint.textContent = `继续前你需要先解锁:当前被 ${accessReasonLabel(activePaywall.reason)} 拦住,推荐用 ${tierText} 继续。`; + } else if (!readerState.sessionId) { + readerDom.readerComposerHint.textContent = "当前还在浏览世界。点“从这个世界开始”进入第一幕后,才能预览或推进。"; + } else if (!hasPlayerInput) { + readerDom.readerComposerHint.textContent = "写下一句你现在真正想做的事,就能先看分岔,再决定是否推进。"; + } else { + readerDom.readerComposerHint.textContent = "现在可以先看命运分岔,再决定要不要推进这一幕。"; + } + } + if (readerDom.readerLandingSummary) { + const worldText = readerState.currentBundle?.label || "还没挑世界"; + const sessionText = readerState.sessionLibrary?.length + ? `有 ${readerState.sessionLibrary.length} 段旅程可续读` + : "还没有可续读旅程"; + const loadedText = readerState.worldId ? "当前世界已就绪" : "当前世界未装载"; + const activeReader = + readerState.readerAuthSession?.identity?.display_name || + readerState.readerAuthSession?.identity?.account_id || + readerState.readerId || + "游客"; + readerDom.readerLandingSummary.textContent = `${worldText} · ${activeReader} · ${sessionText} · ${loadedText}`; + } + } + + function toggleDebugMode(force = null) { + shellState.debug = force === null ? !shellState.debug : Boolean(force); + syncProductMode(); + if (shellState.debug) { + reportUiMessage("已进入调试模式:内部测试权益与调试控制已展开。", "success"); + } else { + reportUiMessage("已关闭调试模式:界面已回到客户可见路径。", "info"); + } + } + + return { + appAccessAuthenticated, + currentShellRoleProfile, + reviewerContentEnabled, + syncViewMode, + syncProductMode, + updateStatus, + toggleDebugMode, + }; +})(); diff --git a/src/narrativeos/web/state_runtime.js b/src/narrativeos/web/state_runtime.js new file mode 100644 index 0000000..eaa0898 --- /dev/null +++ b/src/narrativeos/web/state_runtime.js @@ -0,0 +1,213 @@ +// Shared state runtime extracted from app.js so product runtimes can depend on narrower state buckets. + +var StateRuntime = (() => { + const shellState = { + activeProduct: "reader", + authPage: null, + debug: false, + adminViewEnabled: false, + adminViewBridgeToken: null, + startupRouteProduct: null, + startupRouteWorkspace: null, + readerWorkspace: "landing", + authorWorkspace: "studio", + customerWorkspace: "overview", + opsWorkspace: "dashboard", + initializedGuidedWorkspaces: false, + pendingSessionId: null, + lastReaderView: "experience", + }; + + const readerState = { + examples: [], + shelfWorlds: [], + currentBundle: null, + worldId: null, + worldVersionId: null, + readerId: "reader_demo", + readerAuthSession: null, + readerEntitlements: [], + readerCheckoutSession: null, + readerSubscription: null, + pendingCheckoutSessionId: null, + pendingCheckoutStatus: null, + pendingCheckoutContext: null, + sessionPaywall: null, + sessionId: null, + currentState: null, + latestStep: null, + latestStepFailure: null, + readerGenerationJob: null, + continuityContract: null, + continuityDiagnostics: { + restore_success_count: 0, + paywall_resume_ready_count: 0, + post_checkout_resume_success_count: 0, + quality_guard_context_retained_count: 0, + }, + latestPreview: null, + replay: null, + sessionMedia: { coverImage: "", atmosphereImage: "" }, + intentPrefill: null, + sessionLibrary: [], + authoredWorkLibrary: [], + activeAuthoredWorkPreview: null, + activeTone: "premium_prose", + activeView: "experience", + selectedReplayIndex: null, + selectedIntentOverride: null, + }; + + const authorState = { + authorDrafts: [], + activeDraftVersionId: null, + activeDraftDetail: null, + authorWorks: [], + activeWorkId: null, + activeWorkDetail: null, + activeWorkChapterIndex: null, + activeWorkChapterDetail: null, + activeWorkChapterDraft: null, + activeWorkChapterDirty: false, + activeWorkSaveState: "idle", + pendingAuthorBranchSeed: null, + authorBranchExecutionState: null, + authorWorkDiagnostics: null, + authorWorkQualityGateFailure: null, + authorDraftSection: "assets", + authorValidationReport: null, + authorSimulationReport: null, + authorPreviousSimulationReport: null, + selectedAuthorRevisionIndex: null, + selectedAuthorVolumeId: null, + selectedAuthorArcId: null, + selectedAuthorTaskId: null, + draggingAuthorArcId: null, + draggingAuthorTaskId: null, + selectedAuthorPromiseId: null, + selectedAuthorSimulationChapterIndex: null, + selectedAuthorContinuityChapterIndex: null, + authorRepairLoopHint: null, + authorCurrentRewritePatchExport: null, + authorBriefTemplate: null, + authorAccessSnapshot: null, + authorWorkflowSummary: null, + authorCollaborationSummary: null, + authorReviewerInbox: null, + authorReviewerInboxNextCursor: null, + authorReviewerInboxHasMore: false, + authorReviewerInboxSearch: "", + authorNotificationPreferences: null, + authorAuthSession: null, + selectedAuthorThreadId: null, + authorInlineReplyDraft: "", + authorReviewerInboxVisibleNotificationIds: [], + agentStudio: null, + }; + + const opsState = { + opsReviewQueue: [], + opsReviewHub: null, + opsSelectedReviewItemId: null, + opsSelectedReviewItemDetail: null, + opsSelectedReviewWorkDetail: null, + selectedOpsReleaseBlockerKey: null, + selectedOpsReleaseBlockerCheckKey: null, + opsWorldStatuses: [], + opsWorldHistories: [], + selectedOpsWorldId: null, + opsNavigationModel: null, + opsNavigationPinned: false, + opsInvestigationPinned: false, + opsRefreshRequestId: 0, + opsReleaseWorkspace: null, + opsMeters: [], + opsSchemaLifecycle: null, + opsDataIntegrity: null, + opsDataIntegrityRepair: null, + opsDeploymentHealthGate: null, + opsPreflightVerification: null, + opsDeploymentRunbook: null, + opsIncidentPlaybook: null, + opsRecoveryDrillResult: null, + opsAsyncJobSummary: null, + opsAsyncJobBootReconcile: null, + opsAsyncJobIncidents: null, + opsAsyncJobArtifactRetention: null, + opsAsyncJobOperatorHistory: null, + opsAsyncJobHandoffBundle: null, + opsAsyncJobRemoteShipping: null, + opsAsyncJobHandoffSla: null, + opsAsyncJobAdapterValidation: null, + opsAsyncJobAdapterHealthProbe: null, + opsAsyncJobNotificationReceipts: null, + opsAsyncNotificationRetryQueue: null, + opsAsyncRetryPolicies: null, + opsAsyncNotificationDeadLetterQueue: null, + opsAsyncRetryOutcomeDashboard: null, + opsAsyncJobs: [], + opsRuntimeIncidentSnapshot: null, + opsRuntimeReceipts: [], + opsQualitySummary: null, + opsQualityEvents: [], + opsSelectedQualityTraceId: null, + opsQualityTraceDetail: null, + opsProviderRouting: null, + opsProviderRollout: null, + opsProviderRuntimeMetrics: null, + opsStoryBootstrapWorldSummary: [], + opsStoryBootstrapWorldDetail: null, + opsCommercializationSummary: null, + opsProductionSignoffDetail: null, + opsSubscriptionAudit: null, + opsAccountDetail: null, + opsAccountWorkspace: null, + opsAlertsFeed: null, + opsAlertDetail: null, + selectedOpsAlertId: null, + opsGovernanceSnapshot: null, + opsGovernanceExport: null, + opsGovernanceDetail: null, + opsInvestigationBundle: null, + opsEvalMetrics: null, + opsCrossPackQuality: null, + opsLearnedDashboard: null, + opsLearnedImpact: null, + opsLearnedCadence: null, + opsLearnedAssistedGate: null, + opsLearnedAssistedRerank: null, + opsLearnedReviewQuality: null, + opsLearnedTrainingResult: null, + opsLearnedEvidence: null, + opsLearnedCompare: null, + opsLearnedRollout: null, + opsLearnedDataOps: null, + opsLearnedPromotion: null, + opsLearnedRerankerPromotion: null, + opsPreferenceSamples: [], + opsRankingSamples: [], + opsLearnedDetail: null, + opsLastActionImpact: null, + opsReviewCaptureTarget: null, + }; + + const customerState = { + accountDetail: null, + workspacePayload: null, + selectedCampaignId: null, + }; + + return { + shellState, + readerState, + authorState, + customerState, + opsState, + }; +})(); + +var shellState = StateRuntime.shellState; +var readerState = StateRuntime.readerState; +var authorState = StateRuntime.authorState; +var customerState = StateRuntime.customerState; +var opsState = StateRuntime.opsState; diff --git a/src/narrativeos/web/styles.css b/src/narrativeos/web/styles.css index 7694858..6a5e367 100644 --- a/src/narrativeos/web/styles.css +++ b/src/narrativeos/web/styles.css @@ -81,7 +81,47 @@ button:disabled { padding: 24px 0 40px; } -.app-shell[data-view="backstage"] .workspace { +.app-shell[data-debug="off"] .internal-only { + display: none !important; +} + +.app-shell[data-admin-view="off"] .admin-view-only { + display: none !important; +} + +.app-shell[data-reviewer="off"] .reviewer-only { + display: none !important; +} + +.app-shell[data-reviewer="on"] .reviewer-gate-note { + display: none !important; +} + +.app-shell[data-authenticated="off"] .toolbar-group--mode, +.app-shell[data-authenticated="off"] #product-subnav, +.app-shell[data-authenticated="off"] #reader-landing, +.app-shell[data-authenticated="off"] #reader-shell-v2, +.app-shell[data-authenticated="off"] .featured-world, +.app-shell[data-authenticated="off"] #reader-shell, +.app-shell[data-authenticated="off"] #author-shell, +.app-shell[data-authenticated="off"] #ops-shell { + display: none !important; +} + +.app-shell[data-authenticated="on"] #shell-auth-stage { + display: none !important; +} + +.app-shell[data-authenticated="on"] #reader-auth-controls, +.app-shell[data-authenticated="on"] #author-auth-form { + display: none !important; +} + +.app-shell[data-debug="off"] .author-tech-label { + display: none !important; +} + +.app-shell[data-view="backstage"] #reader-shell { grid-template-columns: minmax(280px, 320px) minmax(0, 1fr) minmax(300px, 360px); } @@ -94,820 +134,3790 @@ button:disabled { display: none; } -.app-shell[data-view="experience"] .workspace, -.app-shell[data-view="storybook"] .workspace { +.app-shell[data-view="experience"] #reader-shell, +.app-shell[data-view="storybook"] #reader-shell { grid-template-columns: minmax(280px, 320px) minmax(0, 1fr); } -.topbar { +#reader-shell-v2 { + display: none; +} + +.app-shell[data-reader-shell="v2"][data-product="reader"][data-auth-page="off"][data-authenticated="on"] #reader-shell-v2 { display: grid; - grid-template-columns: minmax(320px, 1fr) minmax(0, 1.4fr); + gap: 28px; + margin-top: 28px; + animation: rise-in 0.4s ease both; +} + +.app-shell[data-reader-shell="v2"][data-product="reader"][data-auth-page="off"][data-authenticated="on"] #reader-landing, +.app-shell[data-reader-shell="v2"][data-product="reader"][data-auth-page="off"][data-authenticated="on"] .featured-world, +.app-shell[data-reader-shell="v2"][data-product="reader"][data-auth-page="off"][data-authenticated="on"] #reader-shell { + display: none !important; +} + +.reader-shell-v2__hero { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); gap: 24px; - padding: 28px; - border-radius: var(--radius-xl); - border: 1px solid var(--line); + padding: 34px; background: - linear-gradient(135deg, rgba(255, 248, 239, 0.96), rgba(244, 237, 226, 0.76)), - linear-gradient(180deg, rgba(165, 68, 47, 0.08), transparent 64%); - box-shadow: var(--shadow); - backdrop-filter: blur(18px); - animation: rise-in 0.7s ease both; + radial-gradient(circle at 12% 18%, rgba(182, 141, 64, 0.22), transparent 24%), + radial-gradient(circle at 88% 12%, rgba(53, 90, 82, 0.18), transparent 28%), + linear-gradient(140deg, rgba(255, 251, 244, 0.98), rgba(243, 235, 224, 0.86)); } -.brand-block h1 { +.reader-shell-v2__body { + display: grid; + gap: 24px; +} + +.reader-shell-v2__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; +} + +.reader-shell-v2__panel--warning { + border-color: rgba(165, 68, 47, 0.24); + background: rgba(255, 247, 241, 0.92); +} + +.reader-shell-v2__panel--story .rendered-scene { + margin-top: 8px; +} + +.reader-shell-v2__copy { + display: grid; + gap: 14px; + align-content: start; +} + +.reader-shell-v2__copy h2 { margin: 0; font-family: var(--display-font); - font-size: clamp(2.2rem, 4vw, 3.8rem); - line-height: 0.98; + font-size: clamp(2.3rem, 4vw, 4.4rem); + line-height: 0.94; letter-spacing: -0.05em; + max-width: 10ch; } -.eyebrow, -.panel-label, -.toolbar-label { - margin: 0 0 10px; - font-size: 12px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--accent); +.reader-shell-v2__copy .panel-copy { + max-width: 58ch; + font-size: 1rem; + line-height: 1.78; } -.brand-copy, -.panel-copy, -.best-route, -.rendered-scene, -.story-caption, -.list-card-body, -.route-preview, -.story-sequence { - color: var(--ink-soft); +.reader-shell-v2__status-chip { + min-height: 88px; + background: + linear-gradient(180deg, rgba(255, 251, 245, 0.96), rgba(245, 237, 227, 0.88)); } -.brand-copy { - max-width: 56ch; - line-height: 1.7; +.reader-shell-v2__status-chip strong { + font-family: var(--display-font); + font-size: 1.15rem; + line-height: 1.2; } -.topbar-controls { +.reader-shell-v2__spotlight { + overflow: hidden; + padding: 0; + background: + linear-gradient(135deg, rgba(28, 34, 48, 0.95), rgba(62, 43, 27, 0.84)), + radial-gradient(circle at 84% 18%, rgba(182, 141, 64, 0.24), transparent 28%); + color: #f7f1e8; + border-color: rgba(182, 141, 64, 0.18); +} + +.reader-shell-v2__spotlight-grid { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); - gap: 16px; - align-content: start; + grid-template-columns: minmax(0, 1.2fr) minmax(260px, 0.8fr); + gap: 20px; + padding: 30px; } -.toolbar-group { - padding: 16px 18px 18px; - border-radius: var(--radius-lg); - border: 1px solid rgba(29, 30, 42, 0.08); - background: rgba(255, 251, 244, 0.78); +.reader-shell-v2__spotlight-copy { + display: grid; + gap: 14px; } -.toolbar-group--view, -.toolbar-group--status { - grid-column: span 1; +.reader-shell-v2__spotlight-kicker { + margin: 0; + font-size: 12px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: rgba(247, 241, 232, 0.74); } -.toolbar-group--worlds, -.toolbar-group--sessions { - grid-column: span 1; +.reader-shell-v2__spotlight-copy h3, +.reader-shell-v2__read-hero-copy h3 { + margin: 0; + font-family: var(--display-font); + font-size: clamp(1.9rem, 3.2vw, 3rem); + line-height: 0.98; + letter-spacing: -0.04em; } -.toolbar-row { - display: flex; - justify-content: space-between; - align-items: center; +.reader-shell-v2__spotlight-copy p, +.reader-shell-v2__read-hero-copy p { + margin: 0; + line-height: 1.78; + color: rgba(247, 241, 232, 0.82); + max-width: 56ch; +} + +.reader-shell-v2__spotlight-side { + display: grid; gap: 12px; } -.toolbar-caption { - margin: 0; - color: var(--ink-soft); - line-height: 1.55; +.reader-shell-v2__spotlight-stat { + padding: 16px 18px; + border-radius: 20px; + background: rgba(255, 248, 239, 0.08); + border: 1px solid rgba(247, 241, 232, 0.08); + backdrop-filter: blur(10px); } -.toolbar-group select { - width: 100%; - min-height: 44px; - border-radius: 14px; - border: 1px solid rgba(29, 30, 42, 0.1); - background: rgba(255, 252, 246, 0.96); - color: var(--ink); - padding: 0 12px; +.reader-shell-v2__spotlight-stat span { + display: block; + margin-bottom: 6px; + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: rgba(247, 241, 232, 0.68); } -.toolbar-group button, -.primary-action, -.ghost-action, -.segment, -.suggestion-chip, -.tone-pill { - min-height: 44px; - border-radius: 999px; - padding: 10px 16px; - font-size: 0.94rem; +.reader-shell-v2__spotlight-stat strong { + font-size: 1rem; + line-height: 1.4; + color: #fff8ef; } -.world-gallery, -.session-library { +.reader-shell-v2__collection { + padding: 18px; + background: + linear-gradient(180deg, rgba(255, 252, 246, 0.98), rgba(246, 239, 228, 0.86)); +} + +.reader-shell-v2__collection-head { + margin-bottom: 14px; +} + +.reader-shell-v2__collection-head h3 { + margin: 0; + font-size: 1.1rem; +} + +.reader-shell-v2__collection-list { display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; - margin-top: 12px; } -.world-card, -.session-card { - position: relative; - overflow: hidden; +.reader-shell-v2__world-card, +.reader-shell-v2__session-card, +.reader-shell-v2__work-card { + display: grid; + gap: 10px; padding: 18px; - border-radius: 20px; - border: 1px solid rgba(29, 30, 42, 0.08); background: - linear-gradient(180deg, rgba(255, 252, 246, 0.96), rgba(246, 239, 229, 0.82)), - linear-gradient(135deg, rgba(165, 68, 47, 0.08), transparent 58%); - text-align: left; + linear-gradient(180deg, rgba(255, 252, 247, 0.98), rgba(247, 240, 230, 0.9)); } -.world-card::before, -.session-card::before { - content: ""; - position: absolute; - inset: auto 18px 18px auto; - width: 68px; - height: 68px; - border-radius: 999px; - background: radial-gradient(circle, rgba(255, 255, 255, 0.3), transparent 64%); - pointer-events: none; +.reader-shell-v2__card-media, +.reader-shell-v2__spotlight-media, +.reader-shell-v2__read-hero-media, +.reader-shell-v2__image-panel, +.reader-card-media { + margin: 0; + overflow: hidden; + border-radius: 14px; + background: rgba(29, 30, 42, 0.08); } -.world-card[data-example-id="romance"] { - background: - linear-gradient(180deg, rgba(255, 248, 246, 0.98), rgba(244, 232, 232, 0.84)), - linear-gradient(135deg, rgba(126, 63, 94, 0.14), transparent 58%); +.reader-shell-v2__card-media, +.reader-card-media { + aspect-ratio: 16 / 9; } -.world-card.is-selected, -.session-card.is-selected { - border-color: rgba(53, 90, 82, 0.28); - box-shadow: - inset 0 0 0 1px rgba(53, 90, 82, 0.18), - 0 18px 32px rgba(48, 34, 24, 0.08); +.reader-card-media, +.reader-shell-v2__card-media { + margin-bottom: 4px; } -.world-card-title, -.session-card-title { - margin: 0; - font-size: 1.06rem; - line-height: 1.25; +.reader-shell-v2__spotlight-media, +.reader-shell-v2__read-hero-media, +.reader-shell-v2__image-panel { + aspect-ratio: 4 / 3; } -.world-card-body, -.session-card-body { - margin: 10px 0 0; - color: var(--ink-soft); - line-height: 1.65; +.reader-shell-v2__card-media img, +.reader-shell-v2__spotlight-media img, +.reader-shell-v2__read-hero-media img, +.reader-shell-v2__image-panel img, +.reader-card-media img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; } -.world-card-meta, -.session-card-meta { +.reader-shell-v2__spotlight-media { + min-height: 170px; +} + +.reader-shell-v2__eyebrow-row, +.reader-shell-v2__fact-row { display: flex; + justify-content: space-between; + gap: 10px; flex-wrap: wrap; - gap: 8px; - margin-top: 14px; } -.world-card-meta span, -.session-card-meta span { +.reader-shell-v2__eyebrow, +.reader-shell-v2__access { display: inline-flex; align-items: center; - min-height: 30px; - padding: 0 10px; + min-height: 32px; + padding: 0 12px; border-radius: 999px; - background: rgba(255, 255, 255, 0.54); - border: 1px solid rgba(29, 30, 42, 0.08); - font-size: 0.82rem; + font-size: 0.8rem; +} + +.reader-shell-v2__eyebrow { + background: rgba(53, 90, 82, 0.09); + color: var(--jade); +} + +.reader-shell-v2__access { + background: rgba(29, 30, 42, 0.06); color: var(--ink); } -.world-card-actions, -.session-card-actions { +.reader-shell-v2__fact-row span, +.reader-shell-v2__hint-row span, +.reader-shell-v2__suggestion { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0 12px; + border-radius: 999px; + background: rgba(29, 30, 42, 0.06); + color: var(--ink); + font-size: 0.84rem; + line-height: 1.4; +} + +.reader-shell-v2__hint-row { display: flex; gap: 10px; - margin-top: 16px; + flex-wrap: wrap; + margin-top: 18px; } -.world-card button, -.session-card button { - flex: 1; +.reader-shell-v2__read-hero { + padding: 26px 28px; + background: + linear-gradient(145deg, rgba(255, 251, 244, 0.98), rgba(243, 236, 226, 0.86)), + radial-gradient(circle at 82% 16%, rgba(53, 90, 82, 0.12), transparent 26%); } -.featured-world { +.reader-shell-v2__read-hero-grid { display: grid; - grid-template-columns: minmax(0, 1.25fr) minmax(260px, 0.75fr); - gap: 22px; - margin-top: 22px; - background: - linear-gradient(135deg, rgba(255, 248, 239, 0.96), rgba(244, 237, 226, 0.72)), - linear-gradient(90deg, rgba(53, 90, 82, 0.08), transparent 44%); + grid-template-columns: minmax(0, 1fr) minmax(260px, auto); + gap: 18px; + align-items: end; } -.featured-world-copy h2 { - margin: 0; - font-family: var(--display-font); - font-size: clamp(1.8rem, 3vw, 2.9rem); - line-height: 1.02; +.reader-shell-v2__read-hero--with-media .reader-shell-v2__read-hero-grid { + grid-template-columns: minmax(0, 1fr) minmax(180px, 240px) minmax(260px, auto); } -.featured-world-meta { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; +.reader-shell-v2__read-hero-media { + min-height: 150px; } -.featured-world-chip { +.reader-shell-v2__read-grid { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(320px, 0.7fr); + gap: 18px; + align-items: start; +} + +.reader-shell-v2__main-column, +.reader-shell-v2__side-column { + display: grid; + gap: 18px; + align-content: start; +} + +.reader-shell-v2__panel--story { + padding: 26px; + background: + radial-gradient(circle at 12% 12%, rgba(182, 141, 64, 0.12), transparent 22%), + radial-gradient(circle at 88% 10%, rgba(53, 90, 82, 0.12), transparent 24%), + linear-gradient(180deg, rgba(255, 252, 247, 0.98), rgba(244, 237, 227, 0.9)); + overflow: hidden; +} + +.reader-shell-v2__panel--story .panel-head { + margin-bottom: 14px; +} + +.reader-shell-v2__chapter-canvas { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(300px, 0.8fr); + gap: 20px; + align-items: start; +} + +.reader-shell-v2__chapter-main, +.reader-shell-v2__chapter-side { + display: grid; + gap: 18px; + align-content: start; +} + +.reader-shell-v2__canvas-meta { + display: flex; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.reader-shell-v2__canvas-meta span { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0 12px; + border-radius: 999px; + background: rgba(29, 30, 42, 0.06); + color: var(--ink-soft); + font-size: 0.8rem; + line-height: 1.4; +} + +.reader-shell-v2__chapter-recap { + margin: 0; + max-width: 60ch; + color: var(--ink-soft); + font-size: 1rem; + line-height: 1.8; +} + +.reader-shell-v2__prose { + margin-top: 0; + font-family: var(--display-font); + font-size: 1.08rem; + line-height: 2; + padding: 28px; + border-radius: 24px; + border: 0; + background: + linear-gradient(180deg, rgba(255, 253, 248, 0.98), rgba(247, 241, 232, 0.94)); + box-shadow: inset 0 0 0 1px rgba(29, 30, 42, 0.04); +} + +.reader-shell-v2__quote-card, +.reader-shell-v2__beat-card { padding: 18px; + border-radius: 24px; + border: 1px solid rgba(29, 30, 42, 0.08); + background: rgba(255, 251, 244, 0.8); +} + +.reader-shell-v2__section-label { + margin: 0 0 8px; + font-size: 11px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--jade); +} + +.reader-shell-v2__quote { + margin: 0; + padding: 0 0 0 16px; + border-left-width: 2px; + border-radius: 0; + background: transparent; + font-size: 1.14rem; + line-height: 1.72; +} + +.reader-shell-v2__quote-note, +.reader-shell-v2__minor-copy { + margin: 10px 0 0; + color: var(--ink-soft); + line-height: 1.7; +} + +.reader-shell-v2__beat-head { + display: grid; + gap: 6px; +} + +.reader-shell-v2__beat-list { + list-style: none; + margin: 14px 0 0; + padding: 0; + display: grid; + gap: 10px; +} + +.reader-shell-v2__beat-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + align-items: start; + padding-top: 12px; + border-top: 1px solid rgba(29, 30, 42, 0.08); + animation: slide-up 220ms ease both; +} + +.reader-shell-v2__beat-item:first-child { + padding-top: 0; + border-top: 0; +} + +.reader-shell-v2__beat-item--empty { + grid-template-columns: minmax(0, 1fr); +} + +.reader-shell-v2__beat-index { + display: inline-flex; + align-items: center; + min-height: 30px; + padding: 0 8px; + border-radius: 999px; + background: rgba(53, 90, 82, 0.08); + color: var(--jade); + font-family: var(--mono-font); + font-size: 0.74rem; + line-height: 1; +} + +.reader-shell-v2__beat-copy { + color: var(--ink); + line-height: 1.68; +} + +.reader-shell-v2__composer { + position: sticky; + top: 18px; +} + +.reader-shell-v2__suggestion-row { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin: 0 0 14px; +} + +.reader-shell-v2__suggestion { + border: 1px solid rgba(53, 90, 82, 0.14); + background: rgba(53, 90, 82, 0.07); + color: var(--jade); +} + +.reader-shell-v2__story-sequence { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} + +.reader-shell-v2__story-sequence .story-sequence-card { + padding: 14px 16px; + border-radius: 18px; + border: 1px solid rgba(29, 30, 42, 0.08); + background: rgba(255, 251, 244, 0.78); +} + +.reader-shell-v2__story-sequence .story-sequence-card strong { + display: block; + margin-bottom: 6px; + font-size: 0.94rem; + color: var(--ink); +} + +.reader-shell-v2__story-sequence .story-sequence-card p { + margin: 0; + color: var(--ink-soft); + line-height: 1.6; +} + +.reader-shell-v2__meta--storybook { + margin-top: 0; +} + +.reader-shell-v2__meta--storybook span { + background: rgba(53, 90, 82, 0.07); +} + +.reader-shell-v2__trajectory { + display: grid; + gap: 14px; + margin-top: 22px; + padding-top: 22px; + border-top: 1px solid rgba(29, 30, 42, 0.08); +} + +.reader-shell-v2__trajectory-head { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: end; +} + +.reader-shell-v2__trajectory-card { + position: relative; + width: 100%; + display: grid; + gap: 10px; + padding: 16px 18px 18px 22px; + text-align: left; +} + +.reader-shell-v2__trajectory-card::before { + content: ""; + position: absolute; + top: 18px; + bottom: 18px; + left: 10px; + width: 2px; + border-radius: 999px; + background: linear-gradient(180deg, rgba(182, 141, 64, 0.9), rgba(53, 90, 82, 0.7)); + opacity: 0.48; +} + +.reader-shell-v2__trajectory-card:hover { + border-color: rgba(53, 90, 82, 0.22); +} + +.reader-shell-v2__trajectory-card.is-active { + border-color: rgba(53, 90, 82, 0.28); + background: + linear-gradient(180deg, rgba(255, 253, 248, 0.98), rgba(242, 236, 225, 0.94)), + rgba(255, 251, 244, 0.9); + box-shadow: + inset 0 0 0 1px rgba(53, 90, 82, 0.08), + 0 16px 28px rgba(48, 34, 24, 0.08); +} + +.reader-shell-v2__trajectory-card.is-active::before { + opacity: 1; +} + +.reader-shell-v2__trajectory-kicker { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--jade); + font-size: 0.74rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.reader-shell-v2__trajectory-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.reader-shell-v2__trajectory-meta span { + display: inline-flex; + align-items: center; + min-height: 30px; + padding: 0 10px; + border-radius: 999px; + background: rgba(29, 30, 42, 0.05); + color: var(--ink-soft); + font-size: 0.76rem; +} + +.reader-shell-v2__actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 14px; +} + +.reader-shell-v2__actions--wrap { + row-gap: 12px; +} + +.reader-shell-v2__meta { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 12px; + color: var(--ink-soft); +} + +.reader-shell-v2__meta span { + padding: 8px 10px; + border-radius: 999px; + background: rgba(29, 30, 42, 0.06); +} + +.reader-shell-v2__backstage-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.reader-shell-v2__backstage-grid > div { + padding: 16px; + border-radius: 18px; + border: 1px solid rgba(29, 30, 42, 0.08); + background: rgba(255, 251, 244, 0.82); +} + +.reader-shell-v2__backstage-drawer { + position: fixed; + top: 112px; + right: 18px; + bottom: 18px; + width: min(460px, calc(100vw - 36px)); + overflow: auto; + z-index: 40; + padding: 22px; + border-radius: 28px; + border: 1px solid rgba(29, 30, 42, 0.1); + background: + linear-gradient(180deg, rgba(255, 251, 244, 0.98), rgba(244, 237, 227, 0.94)), + linear-gradient(135deg, rgba(53, 90, 82, 0.08), transparent 56%); + box-shadow: 0 28px 80px rgba(20, 18, 17, 0.18); + animation: slide-up 0.26s ease both; +} + +.reader-shell-v2__drawer-head { + display: flex; + justify-content: space-between; + align-items: start; + gap: 14px; +} + +.reader-shell-v2__drawer-head .backstage-close { + white-space: nowrap; +} + +.topbar { + display: grid; + grid-template-columns: minmax(320px, 1fr) minmax(0, 1.4fr); + gap: 24px; + padding: 28px; + border-radius: var(--radius-xl); + border: 1px solid var(--line); + background: + linear-gradient(135deg, rgba(255, 248, 239, 0.96), rgba(244, 237, 226, 0.76)), + linear-gradient(180deg, rgba(165, 68, 47, 0.08), transparent 64%); + box-shadow: var(--shadow); + backdrop-filter: blur(18px); + animation: rise-in 0.7s ease both; +} + +.brand-block h1 { + margin: 0; + font-family: var(--display-font); + font-size: clamp(2.2rem, 4vw, 3.8rem); + line-height: 0.98; + letter-spacing: -0.05em; +} + +.eyebrow, +.panel-label, +.toolbar-label { + margin: 0 0 10px; + font-size: 12px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--accent); +} + +.brand-copy, +.panel-copy, +.best-route, +.rendered-scene, +.story-caption, +.list-card-body, +.route-preview, +.story-sequence { + color: var(--ink-soft); +} + +.brand-copy { + max-width: 56ch; + line-height: 1.7; +} + +.topbar-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); + gap: 16px; + align-content: start; +} + +.toolbar-group { + padding: 16px 18px 18px; + border-radius: var(--radius-lg); + border: 1px solid rgba(29, 30, 42, 0.08); + background: rgba(255, 251, 244, 0.78); +} + +.toolbar-group--view, +.toolbar-group--status { + grid-column: span 1; +} + +.toolbar-group--worlds, +.toolbar-group--sessions, +.toolbar-group--authored { + grid-column: span 1; +} + +.toolbar-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.toolbar-caption { + margin: 0; + color: var(--ink-soft); + line-height: 1.55; +} + +.toolbar-group select { + width: 100%; + min-height: 44px; + border-radius: 14px; + border: 1px solid rgba(29, 30, 42, 0.1); + background: rgba(255, 252, 246, 0.96); + color: var(--ink); + padding: 0 12px; +} + +.toolbar-group button, +.primary-action, +.ghost-action, +.segment, +.suggestion-chip, +.tone-pill { + min-height: 44px; + border-radius: 999px; + padding: 10px 16px; + font-size: 0.94rem; +} + +.world-gallery, +.session-library { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + margin-top: 12px; +} + +.world-card, +.session-card { + position: relative; + overflow: hidden; + padding: 18px; + border-radius: 20px; + border: 1px solid rgba(29, 30, 42, 0.08); + background: + linear-gradient(180deg, rgba(255, 252, 246, 0.96), rgba(246, 239, 229, 0.82)), + linear-gradient(135deg, rgba(165, 68, 47, 0.08), transparent 58%); + text-align: left; +} + +.world-card::before, +.session-card::before { + content: ""; + position: absolute; + inset: auto 18px 18px auto; + width: 68px; + height: 68px; + border-radius: 999px; + background: radial-gradient(circle, rgba(255, 255, 255, 0.3), transparent 64%); + pointer-events: none; +} + +.world-card[data-example-id="romance"] { + background: + linear-gradient(180deg, rgba(255, 248, 246, 0.98), rgba(244, 232, 232, 0.84)), + linear-gradient(135deg, rgba(126, 63, 94, 0.14), transparent 58%); +} + +.world-card.is-selected, +.session-card.is-selected { + border-color: rgba(53, 90, 82, 0.28); + box-shadow: + inset 0 0 0 1px rgba(53, 90, 82, 0.18), + 0 18px 32px rgba(48, 34, 24, 0.08); +} + +.world-card-title, +.session-card-title { + margin: 0; + font-size: 1.06rem; + line-height: 1.25; +} + +.world-card-kicker, +.session-card-kicker, +.landing-context-label { + margin: 0 0 10px; + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent); +} + +.world-card-body, +.session-card-body { + margin: 10px 0 0; + color: var(--ink-soft); + line-height: 1.65; +} + +.world-card-facts { + display: grid; + gap: 10px; + margin: 16px 0 0; +} + +.world-card-facts div { + padding-top: 10px; + border-top: 1px dashed rgba(29, 30, 42, 0.1); +} + +.world-card-facts dt { + margin: 0 0 6px; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-soft); +} + +.world-card-facts dd { + margin: 0; + color: var(--ink); + line-height: 1.5; +} + +.world-card-meta, +.session-card-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +.world-card-meta span, +.session-card-meta span { + display: inline-flex; + align-items: center; + min-height: 30px; + padding: 0 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.54); + border: 1px solid rgba(29, 30, 42, 0.08); + font-size: 0.82rem; + color: var(--ink); +} + +.world-card-actions, +.session-card-actions { + display: flex; + gap: 10px; + margin-top: 16px; +} + +.session-card-utility { + margin-top: 10px; +} + +.session-card-utility .ghost-action { + min-height: 0; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--ink-soft); + text-decoration: underline; + text-underline-offset: 3px; +} + +.world-card button, +.session-card button { + flex: 1; +} + +.featured-world { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(260px, 0.75fr); + gap: 22px; + margin-top: 22px; + background: + linear-gradient(135deg, rgba(255, 248, 239, 0.96), rgba(244, 237, 226, 0.72)), + linear-gradient(90deg, rgba(53, 90, 82, 0.08), transparent 44%); +} + +.featured-world-copy h2 { + margin: 0; + font-family: var(--display-font); + font-size: clamp(1.8rem, 3vw, 2.9rem); + line-height: 1.02; +} + +.featured-world-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.featured-world-chip { + padding: 18px; + border-radius: 18px; + border: 1px solid rgba(29, 30, 42, 0.08); + background: rgba(255, 251, 244, 0.76); +} + +.primary-action { + background: var(--accent); + color: #fff9f3; + box-shadow: 0 10px 24px rgba(127, 44, 28, 0.22); +} + +.primary-action:hover:not(:disabled) { + background: var(--accent-strong); +} + +.ghost-action, +.segment, +.suggestion-chip, +.tone-pill { + border: 1px solid rgba(165, 68, 47, 0.16); + background: rgba(255, 249, 242, 0.72); + color: var(--ink); +} + +.segmented-control, +.composer-actions, +.tone-switcher, +.story-meta, +.commerce-strip { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.composer-context, +.story-feed-hints { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.segment.is-active, +.tone-pill.is-active { + background: rgba(53, 90, 82, 0.12); + border-color: rgba(53, 90, 82, 0.28); + color: var(--jade); +} + +.status-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.status-chip, +.state-grid div, +.story-meta div, +.commerce-chip { + padding: 14px; + border-radius: 16px; + border: 1px solid rgba(29, 30, 42, 0.08); + background: rgba(255, 251, 244, 0.82); +} + +.composer-context-card { + flex: 1 1 220px; + padding: 14px; + border-radius: 16px; + border: 1px solid rgba(29, 30, 42, 0.08); + background: rgba(255, 251, 244, 0.82); +} + +.composer-context-card span { + display: block; + margin-bottom: 6px; + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-soft); +} + +.composer-context-card strong { + font-size: 0.95rem; + line-height: 1.6; + color: var(--ink); +} + +.status-chip span, +.state-grid span, +.story-meta span, +.commerce-chip span, +.meta-list dt { + display: block; + margin-bottom: 6px; + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-soft); +} + +.status-chip strong, +.state-grid strong, +.story-meta strong, +.commerce-chip strong, +.meta-list dd { + margin: 0; + font-size: 0.98rem; + font-weight: 600; + color: var(--ink); +} + +.workspace { + display: grid; + grid-template-columns: minmax(280px, 320px) minmax(0, 1fr); + gap: 20px; + margin-top: 22px; +} + +.workspace.is-hidden { + display: none; +} + +.platform-shell { + grid-template-columns: minmax(0, 1fr); +} + +.rail, +.stage { + display: flex; + flex-direction: column; + gap: 20px; +} + +.view-pane { + display: flex; + flex-direction: column; + gap: 20px; +} + +.view-pane.is-hidden { + display: none; +} + +.panel { + padding: 22px; + border-radius: var(--radius-xl); + border: 1px solid var(--line); + background: var(--panel); + box-shadow: var(--shadow); + backdrop-filter: blur(16px); + animation: rise-in 0.7s ease both; +} + +.stage-panel { + position: relative; +} + +.panel.is-highlighted, +.list-card.is-highlighted { + animation: + rise-in 0.7s ease both, + preview-pulse 1.1s ease; +} + +.paywall-banner { + margin-top: 18px; + padding: 20px 22px; + border-radius: 22px; + border: 1px solid rgba(165, 68, 47, 0.18); + background: + linear-gradient(180deg, rgba(255, 246, 239, 0.98), rgba(250, 233, 224, 0.9)), + linear-gradient(90deg, rgba(165, 68, 47, 0.08), transparent 46%); + color: var(--accent-strong); + line-height: 1.72; +} + +.paywall-banner.is-hidden { + display: none; +} + +.chapter-unlock-card { + margin-top: 18px; + padding: 20px 22px; + border-radius: 22px; + border: 1px solid rgba(165, 68, 47, 0.18); + background: + linear-gradient(180deg, rgba(255, 246, 239, 0.98), rgba(250, 233, 224, 0.9)), + linear-gradient(90deg, rgba(165, 68, 47, 0.08), transparent 46%); + color: var(--accent-strong); +} + +.chapter-unlock-kicker { + margin: 0 0 10px; + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent); +} + +.chapter-unlock-title { + margin: 0; + font-family: var(--display-font); + font-size: 1.45rem; + line-height: 1.08; + color: var(--ink); +} + +.chapter-unlock-body { + margin: 12px 0 0; + line-height: 1.72; + color: var(--ink); +} + +.chapter-unlock-meta { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 16px; +} + +.chapter-unlock-meta span { + display: inline-flex; + align-items: center; + min-height: 36px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid rgba(165, 68, 47, 0.12); + background: rgba(255, 251, 246, 0.82); + color: var(--ink); + font-size: 0.86rem; +} + +.chapter-unlock-actions { + display: flex; + gap: 10px; + margin-top: 16px; +} + +.chapter-unlock-card--storybook { + margin-top: 20px; +} + +.story-feed { + display: flex; + flex-direction: column; + gap: 18px; +} + +.story-feed-card { + padding: 22px; + border-radius: var(--radius-lg); + border: 1px solid rgba(29, 30, 42, 0.08); + background: linear-gradient(180deg, rgba(255, 251, 244, 0.96), rgba(247, 241, 231, 0.82)); +} + +.story-feed-card.is-active { + border-color: rgba(53, 90, 82, 0.28); + box-shadow: inset 0 0 0 1px rgba(53, 90, 82, 0.12); +} + +.story-feed-head h3 { + margin: 0; + font-family: var(--display-font); + font-size: 1.5rem; +} + +.story-feed-recap { + margin: 14px 0 0; + color: var(--ink-soft); + line-height: 1.7; +} + +.story-feed-body { + margin-top: 16px; + white-space: pre-wrap; + line-height: 1.92; + color: var(--ink); +} + +.story-feed-hints { + margin-top: 14px; +} + +.story-feed-hints span { + display: inline-flex; + align-items: center; + min-height: 38px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid rgba(53, 90, 82, 0.12); + background: rgba(53, 90, 82, 0.06); + color: var(--jade); + font-size: 0.88rem; +} + +.panel-head { + margin-bottom: 18px; +} + +.panel-head h2 { + margin: 0; + font-size: 1.3rem; + line-height: 1.2; +} + +.meta-list { + margin: 16px 0 0; + display: grid; + gap: 12px; +} + +.meta-list div { + padding-bottom: 12px; + border-bottom: 1px dashed rgba(29, 30, 42, 0.12); +} + +.meta-list div:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.suggested-inputs { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.suggestion-chip { + text-align: left; + line-height: 1.45; + background: linear-gradient(180deg, rgba(255, 250, 244, 0.9), rgba(247, 239, 229, 0.78)); +} + +.state-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.input-label { + display: inline-block; + margin-bottom: 10px; + font-size: 0.92rem; + font-weight: 600; +} + +.field-input, +.field-select { + width: 100%; + border-radius: 16px; + border: 1px solid rgba(29, 30, 42, 0.1); + background: rgba(255, 252, 246, 0.98); + color: var(--ink); + padding: 14px 16px; + line-height: 1.5; + margin-bottom: 16px; +} + +textarea { + width: 100%; + min-height: 124px; + border-radius: 18px; + border: 1px solid rgba(29, 30, 42, 0.1); + background: rgba(255, 252, 246, 0.98); + color: var(--ink); + padding: 16px 18px; + line-height: 1.7; + resize: vertical; +} + +textarea:focus, +input:focus, +select:focus { + outline: 2px solid rgba(165, 68, 47, 0.2); + border-color: rgba(165, 68, 47, 0.35); +} + +.best-route { + margin: 0 0 18px; + font-family: var(--mono-font); + font-size: 0.88rem; + line-height: 1.6; + color: var(--jade); +} + +.rendered-scene { + margin: 18px 0 0; + padding: 22px; + border-radius: var(--radius-lg); + border: 1px solid rgba(29, 30, 42, 0.08); + background: linear-gradient(180deg, rgba(255, 251, 244, 0.94), rgba(247, 241, 231, 0.72)); + white-space: pre-wrap; + line-height: 1.84; +} + +.route-preview, +.story-sequence, +.list-stack { + display: flex; + flex-direction: column; + gap: 12px; +} + +.empty-state { + color: var(--ink-soft); +} + +.list-card, +.route-line, +.story-sequence-card { + padding: 16px; + border-radius: var(--radius-lg); + border: 1px solid rgba(29, 30, 42, 0.08); + background: var(--panel-strong); +} + +.list-card.is-active { + border-color: rgba(53, 90, 82, 0.24); + box-shadow: + inset 0 0 0 1px rgba(53, 90, 82, 0.12), + 0 14px 24px rgba(48, 34, 24, 0.06); +} + +.route-line { + background: + linear-gradient(180deg, rgba(255, 252, 246, 0.96), rgba(248, 242, 233, 0.82)), + linear-gradient(90deg, rgba(53, 90, 82, 0.06), transparent 40%); +} + +.route-line--placeholder { + opacity: 0.84; + border-style: dashed; +} + +.route-line strong { + display: block; + margin-bottom: 8px; + font-size: 1.02rem; +} + +.route-line .route-rank { + display: inline-flex; + margin-bottom: 10px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(53, 90, 82, 0.08); + color: var(--jade); + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.list-card.is-active, +.story-sequence-card.is-active { + border-color: rgba(53, 90, 82, 0.32); + box-shadow: inset 0 0 0 1px rgba(53, 90, 82, 0.14); +} + +.list-card-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; +} + +.list-card-head h3 { + margin: 0; + font-size: 1rem; +} + +.list-card-score { + font-family: var(--mono-font); + font-size: 0.82rem; + color: var(--jade); +} + +.list-card-body { + margin: 10px 0 0; + white-space: pre-wrap; + line-height: 1.72; + font-size: 0.92rem; +} + +.author-guided-focus-card, +.author-workflow-card, +.author-detail-card { + background: + linear-gradient(180deg, rgba(255, 252, 246, 0.98), rgba(247, 240, 231, 0.88)), + linear-gradient(90deg, rgba(53, 90, 82, 0.06), transparent 48%); +} + +.author-guided-kicker, +.author-workflow-kicker { + margin: 0 0 10px; + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--jade); +} + +.author-guided-focus-card h3, +.author-workflow-head h3 { + margin: 0; + font-size: 1.2rem; + line-height: 1.24; +} + +.author-guided-focus-card p, +.author-workflow-head p { + margin: 8px 0 0; + color: var(--ink-soft); + line-height: 1.7; +} + +.author-guided-meta, +.author-stage-track { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 14px; +} + +.author-guided-meta span, +.author-stage-pill { + display: inline-flex; + align-items: center; + min-height: 36px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid rgba(53, 90, 82, 0.14); + background: rgba(53, 90, 82, 0.06); + color: var(--jade); + font-size: 0.86rem; +} + +.author-guided-actions, +.author-workflow-actions { + margin-top: 14px; +} + +.author-stage-actions { + margin-top: 12px; +} + +.author-simulate-actions { + margin-top: 12px; + margin-bottom: 8px; +} + +.author-workspace-hero { + margin-bottom: 18px; + padding: 18px 20px; + border-radius: 20px; + border: 1px solid rgba(37, 64, 109, 0.12); + background: + linear-gradient(180deg, rgba(248, 251, 255, 0.98), rgba(241, 246, 255, 0.9)), + linear-gradient(90deg, rgba(45, 87, 211, 0.08), transparent 48%); +} + +.author-workspace-hero h3 { + margin: 0; + font-size: 1.22rem; + line-height: 1.24; +} + +.author-workspace-hero .panel-copy { + margin: 10px 0 0; +} + +.author-local-nav { + margin-bottom: 14px; +} + +.author-local-nav .segment { + min-height: 42px; +} + +.author-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + width: 100%; +} + +.author-form-group { + min-width: 0; + min-height: 100%; + padding: 18px; + border-radius: 18px; + border: 1px solid rgba(29, 30, 42, 0.08); + background: rgba(255, 251, 244, 0.82); +} + +.author-form-group:last-child { + grid-column: 1 / -1; +} + +.author-form-group-title { + margin: 0; + font-size: 0.98rem; + font-weight: 700; + color: var(--ink); +} + +.author-form-group-copy { + margin: 8px 0 16px; + color: var(--ink-soft); + line-height: 1.6; +} + +.author-sticky-actions { + position: sticky; + bottom: 12px; + z-index: 20; + margin-top: 18px; + padding: 14px; + border-radius: 18px; + border: 1px solid rgba(37, 64, 109, 0.12); + background: rgba(249, 251, 255, 0.96); + box-shadow: 0 16px 30px rgba(18, 34, 67, 0.08); +} + +.author-tech-label { + display: inline-block; + margin-left: 6px; + font-family: var(--mono-font); + font-size: 0.76rem; + font-weight: 500; + color: var(--ink-soft); +} + +.author-settings-summary-grid, +.author-simulate-work-grid, +.author-workspace-summary-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.author-settings-summary-grid, +.author-simulate-work-grid { + margin-top: 12px; +} + +.author-card-actions { + margin-top: 12px; +} + +.author-summary-card, +.author-work-card { + min-height: 100%; +} + +.author-summary-card.is-warning, +.author-work-card.is-warning { + border-color: rgba(165, 68, 47, 0.24); + box-shadow: inset 0 0 0 1px rgba(165, 68, 47, 0.12); +} + +.author-summary-warning { + margin: 12px 0 0; + color: var(--accent-strong); + line-height: 1.6; +} + +.author-steering-composer { + margin: 0 0 16px; + padding: 18px; + border-radius: 22px; + border: 1px solid rgba(45, 87, 211, 0.16); + background: + linear-gradient(180deg, rgba(248, 251, 255, 0.94), rgba(255, 251, 244, 0.92)), + linear-gradient(135deg, rgba(45, 87, 211, 0.08), transparent 58%); + box-shadow: 0 14px 28px rgba(18, 34, 67, 0.05); +} + +.author-steering-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 12px; +} + +.author-steering-field { + display: grid; + gap: 8px; +} + +.author-steering-field--wide { + grid-column: 1 / -1; +} + +.author-workflow-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 14px; +} + +.author-workflow-grid, +.author-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 16px; +} + +.author-workflow-metric, +.author-detail-section { + padding: 14px; + border-radius: 16px; + border: 1px solid rgba(29, 30, 42, 0.08); + background: rgba(255, 251, 244, 0.82); +} + +.author-workflow-metric span, +.author-detail-section span { + display: block; + margin-bottom: 6px; + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-soft); +} + +.author-workflow-metric strong, +.author-detail-section strong { + display: block; + margin-bottom: 6px; + font-size: 1rem; + color: var(--ink); +} + +.author-workflow-metric p, +.author-detail-section p, +.author-workflow-notes p { + margin: 0; + line-height: 1.65; + color: var(--ink-soft); +} + +.author-detail-section--wide { + grid-column: 1 / -1; +} + +.author-stage-pill.is-complete { + border-color: rgba(53, 90, 82, 0.22); + background: rgba(53, 90, 82, 0.1); +} + +.author-stage-pill.is-active { + border-color: rgba(165, 68, 47, 0.22); + background: rgba(165, 68, 47, 0.1); + color: var(--accent); +} + +.author-stage-pill.is-blocked { + border-color: rgba(165, 68, 47, 0.24); + background: rgba(165, 68, 47, 0.12); + color: var(--accent-strong); +} + +.author-stage-pill.is-pending { + color: var(--ink-soft); + background: rgba(255, 251, 244, 0.82); + border-color: rgba(29, 30, 42, 0.08); +} + +.author-workflow-notes { + display: grid; + gap: 8px; + margin-top: 16px; +} + +.author-editor-shell { + display: grid; + gap: 18px; +} + +.author-editor-hero, +.author-editor-panel { + border-radius: 24px; + border: 1px solid rgba(29, 30, 42, 0.08); + background: + linear-gradient(180deg, rgba(255, 252, 247, 0.98), rgba(246, 239, 230, 0.92)), + linear-gradient(135deg, rgba(45, 87, 211, 0.06), transparent 54%); +} + +.author-editor-hero { + padding: 22px 24px; +} + +.author-editor-hero-head { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.9fr); + gap: 18px; + align-items: start; +} + +.author-editor-kicker { + margin: 0 0 8px; + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--jade); +} + +.author-editor-hero h3, +.author-editor-panel-head h4 { + margin: 0; + font-size: 1.24rem; + line-height: 1.18; +} + +.author-editor-hero .panel-copy { + margin: 10px 0 0; + max-width: 60ch; +} + +.author-editor-hero-status { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.author-editor-status-chip { + min-height: 78px; + padding: 14px; + border-radius: 18px; + border: 1px solid rgba(29, 30, 42, 0.08); + background: rgba(255, 251, 244, 0.86); +} + +.author-editor-status-chip span { + display: block; + margin-bottom: 6px; + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-soft); +} + +.author-editor-status-chip strong { + display: block; + font-size: 1rem; + color: var(--ink); +} + +.author-editor-status-chip.is-complete { + border-color: rgba(53, 90, 82, 0.22); + background: rgba(53, 90, 82, 0.1); +} + +.author-editor-status-chip.is-active { + border-color: rgba(45, 87, 211, 0.18); + background: rgba(45, 87, 211, 0.08); +} + +.author-editor-status-chip.is-blocked { + border-color: rgba(165, 68, 47, 0.24); + background: rgba(165, 68, 47, 0.12); +} + +.author-editor-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 16px; +} + +.author-editor-empty { + padding: 22px 24px; + border-radius: 24px; + border: 1px dashed rgba(45, 87, 211, 0.22); + background: rgba(248, 251, 255, 0.84); +} + +.author-editor-empty h4 { + margin: 0; + font-size: 1.05rem; +} + +.author-editor-empty p, +.author-editor-empty-copy { + margin: 10px 0 0; + line-height: 1.65; + color: var(--ink-soft); +} + +.author-reading-preview-panel { + overflow: hidden; +} + +.author-reading-preview-copy { + margin: 0 0 14px; +} + +.author-reading-preview-feed { + gap: 14px; +} + +.author-reading-preview-meta { + margin: 8px 0 0; + color: var(--ink-soft); + line-height: 1.6; +} + +.author-editor-grid { + display: grid; + grid-template-columns: minmax(240px, 0.76fr) minmax(0, 1.4fr) minmax(280px, 0.92fr); + gap: 16px; + align-items: start; +} + +.author-editor-panel { + padding: 20px; +} + +.author-editor-panel-head { + display: flex; + justify-content: space-between; + align-items: start; + gap: 12px; + margin-bottom: 14px; +} + +.author-editor-inline-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.author-editor-pill { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid rgba(29, 30, 42, 0.08); + background: rgba(255, 251, 244, 0.82); + color: var(--ink-soft); + font-size: 0.84rem; +} + +.author-editor-pill[data-tone="warning"] { + border-color: rgba(165, 68, 47, 0.24); + background: rgba(165, 68, 47, 0.12); + color: var(--accent-strong); +} + +.author-editor-pill[data-tone="success"] { + border-color: rgba(53, 90, 82, 0.22); + background: rgba(53, 90, 82, 0.1); + color: var(--jade); +} + +.author-editor-meta-list { + display: grid; + gap: 10px; + margin-bottom: 14px; +} + +.author-editor-meta-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding-bottom: 10px; + border-bottom: 1px solid rgba(29, 30, 42, 0.08); +} + +.author-editor-meta-row span { + color: var(--ink-soft); +} + +.author-editor-meta-row strong { + color: var(--ink); + text-align: right; +} + +.author-chapter-rail { + display: grid; + gap: 10px; +} + +.author-chapter-row { + width: 100%; + display: block; + padding: 14px; + border-radius: 18px; + border: 1px solid rgba(29, 30, 42, 0.08); + background: rgba(255, 251, 244, 0.84); + text-align: left; + transition: border-color 160ms ease, transform 160ms ease, background 160ms ease; +} + +.author-chapter-row:hover { + transform: translateY(-1px); + border-color: rgba(45, 87, 211, 0.22); +} + +.author-chapter-row.is-active { + border-color: rgba(45, 87, 211, 0.26); + background: rgba(45, 87, 211, 0.08); +} + +.author-chapter-row-copy { + display: grid; + gap: 4px; +} + +.author-chapter-row-index { + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--jade); +} + +.author-chapter-row-copy strong { + font-size: 0.98rem; + color: var(--ink); +} + +.author-chapter-row-meta { + color: var(--ink-soft); + font-size: 0.86rem; +} + +.author-editor-state { + margin: 0 0 14px; + color: var(--ink-soft); + line-height: 1.65; + white-space: pre-wrap; +} + +.author-editor-state[data-tone="success"] { + color: #2b6c55; +} + +.author-editor-state[data-tone="warning"] { + color: #8d5a22; +} + +.author-editor-state[data-tone="danger"] { + color: #9c4333; +} + +.author-editor-state[data-tone="running"] { + color: #305f9c; +} + +.author-editor-form { + display: grid; + gap: 12px; +} + +.author-work-body-input { + min-height: 480px; + font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif; + font-size: 1rem; + line-height: 1.85; +} + +.author-editor-main-actions { + display: flex; + justify-content: flex-end; + padding-top: 4px; +} + +.author-editor-sidecar { + display: grid; + gap: 12px; +} + +.author-editor-sidecard { + padding: 16px; + border-radius: 18px; + border: 1px solid rgba(29, 30, 42, 0.08); + background: rgba(255, 251, 244, 0.84); +} + +.author-editor-sidecard h5 { + margin: 0 0 8px; + font-size: 0.96rem; +} + +.author-editor-sidecard p { + margin: 0; + color: var(--ink-soft); + line-height: 1.7; + white-space: pre-wrap; +} + +.author-choice-list { + display: grid; + gap: 12px; +} + +.author-choice-item { + padding-top: 12px; + border-top: 1px solid rgba(29, 30, 42, 0.08); +} + +.author-choice-item:first-child { + padding-top: 0; + border-top: 0; +} + +.author-branch-status-card[data-tone="running"] { + border-color: rgba(72, 114, 184, 0.22); + background: + linear-gradient(180deg, rgba(245, 249, 255, 0.98), rgba(233, 242, 255, 0.94)), + rgba(255, 255, 255, 0.84); +} + +.author-branch-status-card[data-tone="running"] .list-card-score { + color: #305f9c; +} + +.author-branch-status-card[data-tone="success"] { + border-color: rgba(53, 109, 87, 0.18); + background: + linear-gradient(180deg, rgba(246, 253, 249, 0.98), rgba(234, 246, 239, 0.94)), + rgba(255, 255, 255, 0.84); +} + +.author-branch-status-card[data-tone="success"] .list-card-score { + color: #2b6c55; +} + +.author-branch-status-card[data-tone="warning"], +.author-branch-status-card[data-tone="danger"] { + border-color: rgba(160, 96, 44, 0.2); + background: + linear-gradient(180deg, rgba(255, 248, 240, 0.98), rgba(252, 238, 225, 0.94)), + rgba(255, 255, 255, 0.84); +} + +.author-branch-status-card[data-tone="warning"] .list-card-score, +.author-branch-status-card[data-tone="danger"] .list-card-score { + color: #8d5a22; +} + +.author-choice-item strong { + display: block; + margin-bottom: 6px; + color: var(--ink); +} + +.author-choice-item p { + margin-bottom: 10px; +} + +.author-cockpit-card { + overflow: hidden; +} + +.author-relationship-network { + width: 100%; + height: auto; + margin-top: 10px; + border-radius: 18px; + background: + radial-gradient(circle at 20% 20%, rgba(45, 87, 211, 0.08), transparent 22%), + radial-gradient(circle at 78% 22%, rgba(165, 68, 47, 0.08), transparent 22%), + rgba(250, 252, 255, 0.92); + min-height: 220px; +} + +.author-relationship-network-node { + fill: rgba(255, 251, 244, 0.98); + stroke: rgba(45, 87, 211, 0.28); + stroke-width: 1.5; +} + +.author-relationship-network-title, +.author-relationship-network-subtitle, +.author-relationship-network-label { + fill: var(--ink); + font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif; +} + +.author-relationship-network-title { + font-size: 12px; + font-weight: 700; +} + +.author-relationship-network-subtitle { + font-size: 9px; + fill: var(--ink-soft); +} + +.author-relationship-network-label { + font-size: 10px; + fill: var(--accent-strong); + stroke: rgba(250, 252, 255, 0.96); + stroke-width: 3px; + paint-order: stroke fill; +} + +.author-cockpit-legend { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.author-cockpit-row { + padding-top: 12px; + border-top: 1px solid rgba(29, 30, 42, 0.08); +} + +.author-cockpit-row:first-child { + padding-top: 0; + border-top: 0; +} + +.author-cockpit-legend-item { + margin: 0; + color: var(--ink-soft); + line-height: 1.65; +} + +.author-cockpit-row-copy { + margin: 0; + color: var(--ink-soft); + line-height: 1.7; + white-space: pre-wrap; +} + +.author-cockpit-row-copy strong { + color: var(--ink); +} + +.author-heatmap-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(84px, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.author-issue-priority-grid { + display: grid; + gap: 12px; + margin-top: 12px; +} + +.author-heatmap-summary-badges { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.author-heatmap-summary-badge, +.author-heatmap-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 9px; + border-radius: 999px; + border: 1px solid rgba(45, 87, 211, 0.14); + background: rgba(255, 255, 255, 0.76); + color: var(--accent-strong); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.author-heatmap-badge.is-overflow { + border-style: dashed; + color: var(--ink-soft); +} + +.author-heatmap-cell { + display: grid; + gap: 4px; + min-height: 88px; + padding: 10px; + border-radius: 16px; + border: 1px solid rgba(29, 30, 42, 0.08); + background: rgba(255, 251, 244, 0.84); + text-align: left; +} + +.author-heatmap-cell-badges { + display: flex; + flex-wrap: wrap; + gap: 4px; + min-height: 24px; +} + +.author-heatmap-cell strong { + font-size: 1rem; + color: var(--ink); +} + +.author-heatmap-cell span { + font-size: 0.82rem; + color: var(--ink-soft); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.author-heatmap-cell small { + color: var(--ink-soft); + line-height: 1.4; +} + +.author-heatmap-cell.is-stable { + border-color: rgba(53, 90, 82, 0.18); + background: rgba(53, 90, 82, 0.08); +} + +.author-heatmap-cell.is-watch { + border-color: rgba(209, 149, 61, 0.24); + background: rgba(209, 149, 61, 0.12); +} + +.author-heatmap-cell.is-critical { + border-color: rgba(165, 68, 47, 0.26); + background: rgba(165, 68, 47, 0.14); +} + +.author-panel-copy { + margin: 0 0 12px; + color: var(--ink-soft); + line-height: 1.6; +} + +.story-panel { + overflow: hidden; +} + +.story-hero { + position: relative; + min-height: 320px; + border-radius: var(--radius-xl); + background: + radial-gradient(circle at 20% 20%, rgba(245, 206, 109, 0.28), transparent 24%), + radial-gradient(circle at 80% 22%, rgba(53, 90, 82, 0.24), transparent 22%), + linear-gradient(135deg, rgba(165, 68, 47, 0.94), rgba(53, 90, 82, 0.88)); + display: flex; + align-items: flex-end; + padding: 26px; +} + +.story-hero--with-image { + background-size: cover; + background-position: center; +} + +.story-hero[data-motif="temptation"] { + background: + radial-gradient(circle at 18% 20%, rgba(246, 209, 159, 0.34), transparent 24%), + radial-gradient(circle at 82% 24%, rgba(102, 49, 94, 0.3), transparent 22%), + linear-gradient(135deg, rgba(95, 49, 79, 0.95), rgba(187, 107, 80, 0.88)); +} + +.story-hero[data-motif="discovery"] { + background: + radial-gradient(circle at 16% 18%, rgba(236, 219, 176, 0.34), transparent 22%), + radial-gradient(circle at 78% 22%, rgba(86, 116, 160, 0.24), transparent 24%), + linear-gradient(135deg, rgba(37, 56, 95, 0.96), rgba(139, 95, 64, 0.9)); +} + +.story-hero-overlay { + max-width: 62ch; + color: #fff8f2; +} + +.story-hero-overlay h2 { + margin: 0; + font-family: var(--display-font); + font-size: clamp(2rem, 4.2vw, 3.5rem); + line-height: 0.96; +} + +.story-caption { + margin: 14px 0 0; + color: rgba(255, 248, 242, 0.86); + line-height: 1.7; +} + +.story-quote { + margin: 18px 0 0; + padding: 18px 20px; + border-left: 3px solid rgba(165, 68, 47, 0.5); + border-radius: 0 18px 18px 0; + background: rgba(255, 249, 240, 0.76); + font-family: var(--display-font); + font-size: 1.2rem; + line-height: 1.55; + color: var(--ink); +} + +.story-section { + margin-top: 18px; +} + +.story-recap { + margin: 0; + color: var(--ink-soft); + line-height: 1.72; +} + +.story-beats, +.story-details { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 16px; +} + +.story-beat, +.story-detail { + display: inline-flex; + align-items: center; + min-height: 38px; + padding: 0 14px; + border-radius: 999px; + border: 1px solid rgba(29, 30, 42, 0.08); + background: rgba(255, 252, 246, 0.92); + color: var(--ink); + font-size: 0.9rem; + line-height: 1.4; +} + +.story-detail { + min-height: 42px; + border-radius: 16px; +} + +.story-sequence-card { + cursor: pointer; +} + +.story-sequence-card h3 { + margin: 0 0 8px; + font-size: 1rem; +} + +.panel-head-row { + display: flex; + justify-content: space-between; + align-items: start; + gap: 12px; +} + +.backstage-close { + white-space: nowrap; +} + +@keyframes rise-in { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes preview-pulse { + 0% { + box-shadow: var(--shadow), 0 0 0 0 rgba(165, 68, 47, 0.26); + } + 55% { + box-shadow: var(--shadow), 0 0 0 14px rgba(165, 68, 47, 0); + } + 100% { + box-shadow: var(--shadow), 0 0 0 0 rgba(165, 68, 47, 0); + } +} + +@keyframes slide-up { + from { + opacity: 0; + transform: translateY(18px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@media (max-width: 1320px) { + .topbar { + grid-template-columns: 1fr; + } + + .topbar-controls { + grid-template-columns: 1fr; + } + + .reader-shell-v2__read-hero-grid { + grid-template-columns: 1fr; + align-items: stretch; + } + + .reader-shell-v2__read-hero-media { + max-width: 360px; + } + + .reader-shell-v2__read-grid, + .reader-shell-v2__chapter-canvas { + grid-template-columns: 1fr; + } + + .reader-shell-v2__canvas-meta, + .reader-shell-v2__trajectory-head { + justify-content: flex-start; + } + + .reader-shell-v2__story-sequence { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .featured-world { + grid-template-columns: 1fr; + } + + .workspace, + .app-shell[data-view="backstage"] #reader-shell, + .app-shell[data-view="experience"] #reader-shell, + .app-shell[data-view="storybook"] #reader-shell { + grid-template-columns: 1fr; + } + + .app-shell[data-view="backstage"] .rail-right, + .app-shell[data-view="experience"] .rail-right, + .app-shell[data-view="storybook"] .rail-right { + display: flex; + } +} + +@media (max-width: 780px) { + .app-shell { + width: min(calc(100vw - 18px), var(--max-width)); + padding: 12px 0 28px; + } + + .topbar, + .panel { + padding: 18px; + } + + .state-grid, + .status-grid { + grid-template-columns: 1fr 1fr; + } + + .world-gallery, + .session-library { + grid-template-columns: 1fr; + } + + .story-hero { + min-height: 240px; + } + + .reader-shell-v2__panel--story, + .reader-shell-v2__read-hero { + padding: 18px; + } + + .reader-shell-v2__prose { + padding: 20px; + font-size: 1rem; + line-height: 1.9; + } + + .reader-shell-v2__story-sequence { + grid-template-columns: 1fr; + } +} + +.is-hidden { + display: none !important; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: fixed; + top: 14px; + left: 14px; + z-index: 200; + padding: 10px 14px; + border-radius: 999px; + background: rgba(20, 26, 42, 0.96); + color: #fff; + text-decoration: none; + transform: translateY(-160%); + transition: transform 180ms ease; +} + +.skip-link:focus-visible { + transform: translateY(0); +} + +.app-shell[data-product="reader"] { + --accent: #a5442f; + --accent-strong: #7f2c1c; +} + +.app-shell[data-product="author"] { + --accent: #2d57d3; + --accent-strong: #173481; + --jade: #25406d; +} + +.app-shell[data-product="author"] .topbar { + padding: 22px 24px; + gap: 14px; +} + +.app-shell[data-product="author"] .topbar-hero { + grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.85fr); + gap: 18px; +} + +.app-shell[data-product="author"] .shell-toolbar { + grid-template-columns: minmax(180px, 0.7fr) minmax(260px, 1fr); + gap: 12px; + align-items: start; +} + +.app-shell[data-product="author"] .toolbar-group--mode, +.app-shell[data-product="author"] .toolbar-group--utility { + min-height: 100%; +} + +.app-shell[data-product="author"] .brand-block h1 { + font-size: clamp(1.9rem, 3vw, 3rem); + line-height: 1.02; +} + +.app-shell[data-product="author"] .brand-copy { + max-width: 46ch; + line-height: 1.6; +} + +.app-shell[data-product="author"] .toolbar-group { + padding: 14px 16px 16px; +} + +.app-shell[data-product="author"] .utility-stack { + gap: 10px; +} + +.app-shell[data-product="author"] .shell-subnav { + padding-top: 10px; + align-items: center; +} + +.app-shell[data-product="author"] #author-shell, +.app-shell[data-product="author"] #author-shell .workspace-stack, +.app-shell[data-product="author"] #author-shell .product-workspace, +.app-shell[data-product="author"] #author-shell .stage, +.app-shell[data-product="author"] #author-shell .panel { + width: 100%; + min-width: 0; +} + +.app-shell[data-product="author"] #author-shell .panel { + align-self: stretch; +} + +.app-shell[data-product="ops"] { + --accent: #183042; + --accent-strong: #0d1a24; + --jade: #0b5a65; +} + +.topbar { + display: flex; + flex-direction: column; + gap: 18px; + padding: 28px; + overflow: hidden; + border-radius: 36px; + background: + radial-gradient(circle at top right, rgba(255, 255, 255, 0.16), transparent 30%), + linear-gradient(140deg, rgba(21, 24, 39, 0.94), rgba(70, 33, 24, 0.78) 54%, rgba(22, 38, 52, 0.92)); + color: #fff7ef; +} + +.topbar-hero { + display: grid; + grid-template-columns: minmax(320px, 1.3fr) minmax(280px, 0.95fr); + gap: 24px; + align-items: start; +} + +.shell-toolbar { + display: grid; + gap: 14px; +} + +.topbar .shell-toolbar .toolbar-group { + border: 1px solid rgba(74, 57, 47, 0.18); + background: rgba(255, 249, 243, 0.92); + box-shadow: 0 14px 30px rgba(18, 16, 18, 0.08); +} + +.topbar .shell-toolbar .toolbar-label, +.topbar .shell-toolbar .toolbar-caption { + color: rgba(70, 49, 36, 0.86); +} + +.topbar .shell-toolbar .segment, +.topbar .shell-toolbar .ghost-action, +.topbar .shell-toolbar .primary-action { + color: rgba(58, 42, 32, 0.9); +} + +.topbar .shell-toolbar .segment, +.topbar .shell-toolbar .ghost-action { + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(122, 98, 82, 0.18); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4); +} + +.topbar .shell-toolbar .segment.is-active, +.topbar .shell-toolbar .primary-action { + background: rgba(165, 68, 47, 0.18); + border: 1px solid rgba(140, 58, 39, 0.28); + color: rgba(86, 38, 26, 0.96); +} + +.topbar .shell-toolbar .segment:hover:not(:disabled), +.topbar .shell-toolbar .ghost-action:hover:not(:disabled), +.topbar .shell-toolbar .primary-action:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.94); + color: rgba(43, 32, 25, 0.98); +} + +.topbar .shell-toolbar .segment.is-active:hover:not(:disabled), +.topbar .shell-toolbar .primary-action:hover:not(:disabled) { + background: rgba(165, 68, 47, 0.24); +} + +.toolbar-group--utility .toolbar-label, +.shell-subnav .toolbar-label, +.topbar .eyebrow { + color: rgba(255, 229, 207, 0.84); +} + +.topbar .brand-copy, +.topbar .toolbar-caption { + color: rgba(255, 241, 232, 0.76); +} + +.topbar .segment, +.topbar .ghost-action, +.topbar .primary-action { + background: rgba(255, 251, 247, 0.08); + color: #fff7ef; + border-color: rgba(255, 240, 225, 0.14); + backdrop-filter: blur(14px); +} + +.topbar .segment.is-active, +.topbar .primary-action { + background: rgba(255, 247, 240, 0.16); +} + +.utility-stack { + display: grid; + gap: 12px; +} + +.status-grid--compact { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.topbar .shell-toolbar .status-grid--compact .status-chip, +.topbar .shell-toolbar .session-summary { + border: 1px solid rgba(122, 98, 82, 0.16); + background: rgba(255, 255, 255, 0.84); +} + +.status-grid--compact .status-chip { + background: rgba(255, 250, 246, 0.09); + border-color: rgba(255, 238, 223, 0.14); +} + +.topbar .shell-toolbar .status-grid--compact .status-chip span { + color: rgba(116, 86, 68, 0.74); +} + +.topbar .shell-toolbar .status-grid--compact .status-chip strong, +.topbar .shell-toolbar #shell-session-copy { + color: rgba(46, 32, 24, 0.96); +} + +.status-grid--compact .status-chip span { + color: rgba(255, 236, 220, 0.68); +} + +.status-grid--compact .status-chip strong { + color: #fffaf4; +} + +.shell-subnav { + display: flex; + justify-content: space-between; + align-items: end; + gap: 16px; + padding-top: 6px; + border-top: 1px solid rgba(255, 240, 225, 0.12); +} + +.landing-shell { + display: flex; + flex-direction: column; + gap: 18px; + margin-top: 22px; +} + +.shell-auth-stage { + margin-top: 22px; + overflow: hidden; + background: + linear-gradient(135deg, rgba(255, 251, 246, 0.97), rgba(240, 232, 222, 0.9)), + linear-gradient(125deg, rgba(165, 68, 47, 0.12), transparent 55%); +} + +.shell-auth-hero { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); + gap: 24px; + align-items: stretch; +} + +.shell-auth-copy { + display: grid; + gap: 20px; +} + +.shell-auth-copy h2 { + margin: 0; + font-family: var(--display-font); + font-size: clamp(2rem, 3.4vw, 3.1rem); + line-height: 1.02; + letter-spacing: -0.04em; +} + +.shell-auth-notes { + display: grid; + gap: 10px; + max-width: 36rem; + padding-top: 4px; +} + +.shell-auth-notes p { + margin: 0; + color: var(--ink-soft); + line-height: 1.65; +} + +.shell-auth-notes p::before { + content: "·"; + display: inline-block; + margin-right: 10px; + color: var(--accent); +} + +.shell-auth-panel { + display: grid; + gap: 14px; + padding: 22px; + border-radius: 28px; + border: 1px solid rgba(29, 30, 42, 0.08); + background: rgba(255, 252, 247, 0.9); + box-shadow: 0 18px 38px rgba(20, 17, 20, 0.08); +} + +.shell-auth-role-switch { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.shell-auth-role-switch .segment { + width: 100%; +} + +.shell-auth-stage.is-authenticated .shell-auth-panel { + background: rgba(255, 251, 245, 0.94); +} + +.session-summary { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid rgba(255, 238, 223, 0.14); + background: rgba(255, 250, 246, 0.09); +} + +.session-summary .toolbar-label { + display: block; + margin-bottom: 6px; +} + +.topbar .shell-toolbar #shell-session-logout { + min-width: 104px; + justify-self: end; +} + +.landing-hero { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(260px, 0.75fr); + gap: 18px; + background: + linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(244, 235, 223, 0.88)), + linear-gradient(135deg, rgba(165, 68, 47, 0.12), transparent 58%); +} + +.landing-actions, +.featured-world-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 18px; +} + +.landing-context { + margin-top: 18px; + padding: 16px 18px; border-radius: 18px; border: 1px solid rgba(29, 30, 42, 0.08); - background: rgba(255, 251, 244, 0.76); + background: rgba(255, 251, 246, 0.72); } -.primary-action { - background: var(--accent); - color: #fff9f3; - box-shadow: 0 10px 24px rgba(127, 44, 28, 0.22); +.landing-context-copy { + margin: 0; + color: var(--ink); + line-height: 1.6; } -.primary-action:hover:not(:disabled) { - background: var(--accent-strong); +.landing-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 18px; } -.ghost-action, -.segment, -.suggestion-chip, -.tone-pill { - border: 1px solid rgba(165, 68, 47, 0.16); - background: rgba(255, 249, 242, 0.72); +.landing-panel { + min-height: 100%; +} + +.workspace-stack, +.product-workspace { + display: flex; + flex-direction: column; + gap: 20px; +} + +.status-banner { + margin-top: 18px; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid rgba(29, 30, 42, 0.08); + background: rgba(255, 251, 244, 0.92); color: var(--ink); + line-height: 1.6; } -.segmented-control, -.composer-actions, -.tone-switcher, -.story-meta, -.commerce-strip { +.status-banner--info { + border-color: rgba(53, 90, 82, 0.18); + background: rgba(243, 249, 247, 0.94); +} + +.status-banner--success { + border-color: rgba(53, 90, 82, 0.22); + background: rgba(238, 248, 244, 0.96); + color: var(--jade); +} + +.status-banner--warning { + border-color: rgba(182, 141, 64, 0.28); + background: rgba(253, 247, 233, 0.96); +} + +.status-banner--error { + border-color: rgba(165, 68, 47, 0.24); + background: rgba(255, 241, 236, 0.98); + color: var(--accent-strong); +} + +.toast-stack { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 120; + display: flex; + flex-direction: column; + gap: 10px; + width: min(360px, calc(100vw - 28px)); +} + +.toast { + padding: 14px 16px; + border-radius: 18px; + border: 1px solid rgba(29, 30, 42, 0.08); + background: rgba(255, 252, 248, 0.96); + box-shadow: 0 16px 32px rgba(15, 18, 28, 0.14); + line-height: 1.55; + transition: opacity 180ms ease, transform 180ms ease; +} + +.toast--warning { + background: rgba(253, 248, 236, 0.98); +} + +.toast--error { + background: rgba(255, 241, 236, 0.98); + color: var(--accent-strong); +} + +.toast--success { + background: rgba(238, 248, 244, 0.98); + color: var(--jade); +} + +.toast--leaving { + opacity: 0; + transform: translateY(8px); +} + +.auth-route-stage { + display: grid; + gap: 18px; +} + +.auth-route-content { + display: grid; + gap: 14px; +} + +.auth-route-card { + display: grid; + gap: 12px; + padding: 20px; + border-radius: 22px; + background: rgba(255, 255, 255, 0.78); + border: 1px solid rgba(24, 32, 54, 0.08); + box-shadow: 0 18px 40px rgba(17, 24, 39, 0.08); +} + +.auth-route-card p { + margin: 0; + white-space: pre-line; +} + +.auth-route-inline-actions { display: flex; flex-wrap: wrap; gap: 10px; } -.composer-context, -.story-feed-hints { - display: flex; - flex-wrap: wrap; - gap: 10px; +.auth-route-inputs { + display: grid; + gap: 10px; +} + +.app-shell[data-auth-page="on"] .topbar, +.app-shell[data-auth-page="on"] #reader-landing, +.app-shell[data-auth-page="on"] #reader-shell-v2, +.app-shell[data-auth-page="on"] #reader-shell, +.app-shell[data-auth-page="on"] #author-shell, +.app-shell[data-auth-page="on"] #customer-shell, +.app-shell[data-auth-page="on"] #ops-shell { + display: none; +} + +.app-shell[data-auth-page="on"] #shell-auth-stage.is-hidden, +.app-shell[data-auth-page="on"] #auth-route-stage.is-hidden { + display: none; +} + +.field-hint { + margin: 12px 0 0; + color: var(--ink-soft); + line-height: 1.55; +} + +.reader-debug-tools { + margin-top: 16px; + padding-top: 16px; + border-top: 1px dashed rgba(29, 30, 42, 0.12); +} + +.reader-debug-tools.is-hidden { + display: none !important; +} + +.featured-world { + margin-top: 22px; +} + +.app-shell[data-product="reader"] .featured-world { + display: grid; +} + +.app-shell[data-product="author"] .featured-world, +.app-shell[data-product="ops"] .featured-world { + display: none; +} + +.app-shell[data-product="reader"] #reader-shell, +.app-shell[data-product="reader"][data-view="backstage"] #reader-shell { + grid-template-columns: minmax(280px, 320px) minmax(0, 1fr); +} + +.app-shell[data-product="reader"][data-reader-shell="v2"] .featured-world { + display: none; +} + +.app-shell[data-product="reader"] #backstage-view { + position: fixed; + top: 124px; + right: 20px; + bottom: 20px; + width: min(420px, calc(100vw - 28px)); + max-height: calc(100vh - 144px); + overflow: auto; + z-index: 90; + padding-right: 4px; + background: transparent; +} + +.app-shell[data-product="reader"] #backstage-view .panel { + box-shadow: 0 24px 56px rgba(18, 23, 34, 0.14); +} + +.app-shell[data-product="author"][data-author-workspace="studio"] { + width: min(calc(100vw - 24px), 1700px); + padding-top: 14px; +} + +.app-shell[data-product="author"][data-author-workspace="studio"] .topbar { + padding: 10px 14px; + gap: 8px; + border-radius: 20px; +} + +.app-shell[data-product="author"][data-author-workspace="studio"] .topbar-hero { + grid-template-columns: auto minmax(520px, 1fr); + gap: 12px; + align-items: center; +} + +.app-shell[data-product="author"][data-author-workspace="studio"] .brand-block h1 { + display: none; +} + +.app-shell[data-product="author"][data-author-workspace="studio"] .brand-copy, +.app-shell[data-product="author"][data-author-workspace="studio"] .toolbar-group--utility > .toolbar-label, +.app-shell[data-product="author"][data-author-workspace="studio"] .toolbar-group--utility .status-grid--compact, +.app-shell[data-product="author"][data-author-workspace="studio"] #shell-context-copy { + display: none; +} + +.app-shell[data-product="author"][data-author-workspace="studio"] .shell-toolbar { + grid-template-columns: auto minmax(300px, 1fr); + gap: 8px; + align-items: center; +} + +.app-shell[data-product="author"][data-author-workspace="studio"] .toolbar-group { + padding: 8px 10px; } -.segment.is-active, -.tone-pill.is-active { - background: rgba(53, 90, 82, 0.12); - border-color: rgba(53, 90, 82, 0.28); - color: var(--jade); +.app-shell[data-product="author"][data-author-workspace="studio"] .utility-stack { + gap: 6px; } -.status-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; +.app-shell[data-product="author"][data-author-workspace="studio"] .session-summary { + padding: 8px 10px; + border-radius: 12px; } -.status-chip, -.state-grid div, -.story-meta div, -.commerce-chip { - padding: 14px; - border-radius: 16px; - border: 1px solid rgba(29, 30, 42, 0.08); - background: rgba(255, 251, 244, 0.82); +.app-shell[data-product="author"][data-author-workspace="studio"] .session-summary .toolbar-label { + display: none; } -.composer-context-card { - flex: 1 1 220px; - padding: 14px; - border-radius: 16px; - border: 1px solid rgba(29, 30, 42, 0.08); - background: rgba(255, 251, 244, 0.82); +.app-shell[data-product="author"][data-author-workspace="studio"] #shell-session-copy { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.composer-context-card span { - display: block; - margin-bottom: 6px; - font-size: 11px; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--ink-soft); +.app-shell[data-product="author"][data-author-workspace="studio"] .shell-subnav { + padding-top: 4px; + gap: 10px; } -.composer-context-card strong { - font-size: 0.95rem; - line-height: 1.6; - color: var(--ink); +.app-shell[data-product="author"][data-author-workspace="studio"] .shell-subnav .toolbar-caption { + margin-bottom: 0; } -.status-chip span, -.state-grid span, -.story-meta span, -.commerce-chip span, -.meta-list dt { - display: block; - margin-bottom: 6px; - font-size: 11px; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--ink-soft); +.app-shell[data-product="author"][data-author-workspace="studio"] .shell-subnav .toolbar-label, +.app-shell[data-product="author"][data-author-workspace="studio"] .shell-subnav .toolbar-caption { + font-size: 0.82rem; } -.status-chip strong, -.state-grid strong, -.story-meta strong, -.commerce-chip strong, -.meta-list dd { - margin: 0; - font-size: 0.98rem; - font-weight: 600; - color: var(--ink); +.app-shell[data-product="author"][data-author-workspace="studio"] #shell-status-banner { + display: none; } -.workspace { - display: grid; - grid-template-columns: minmax(280px, 320px) minmax(0, 1fr); - gap: 20px; - margin-top: 22px; +.agent-studio-panel { + padding: clamp(16px, 2vw, 24px); + overflow: visible; + background: + linear-gradient(135deg, rgba(8, 16, 24, 0.98), rgba(17, 19, 32, 0.97) 54%, rgba(18, 30, 39, 0.98)); + color: #f2efe6; + border-color: rgba(129, 215, 220, 0.2); + box-shadow: 0 24px 70px rgba(7, 11, 17, 0.34); } -.workspace.is-hidden { - display: none; +.agent-studio-panel .panel-label, +.agent-studio-panel .input-label, +.agent-studio-panel .field-hint { + color: rgba(231, 247, 248, 0.72); } -.platform-shell { - grid-template-columns: minmax(0, 1fr); +.agent-studio-panel .field-input, +.agent-studio-panel .field-select, +.agent-studio-panel textarea { + background: rgba(255, 255, 255, 0.08); + color: #fffaf0; + border-color: rgba(214, 232, 228, 0.2); } -.rail, -.stage { - display: flex; - flex-direction: column; - gap: 20px; +.agent-studio-start { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(320px, 0.65fr); + gap: 28px; + min-height: 620px; + align-items: center; } -.view-pane { - display: flex; - flex-direction: column; - gap: 20px; +.agent-studio-start-copy h2 { + max-width: 720px; + font-size: clamp(2.4rem, 6vw, 5.4rem); + line-height: 0.96; + margin: 0 0 22px; + letter-spacing: 0; } -.view-pane.is-hidden { - display: none; +.agent-studio-start-copy p:not(.panel-label) { + max-width: 620px; + color: rgba(245, 241, 228, 0.74); + font-size: 1.08rem; + line-height: 1.8; } -.panel { +.agent-studio-start-form { + display: grid; + gap: 12px; padding: 22px; - border-radius: var(--radius-xl); - border: 1px solid var(--line); - background: var(--panel); - box-shadow: var(--shadow); - backdrop-filter: blur(16px); - animation: rise-in 0.7s ease both; + border: 1px solid rgba(218, 231, 224, 0.16); + background: rgba(255, 255, 255, 0.07); + border-radius: 8px; } -.stage-panel { - position: relative; +.agent-studio-field-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 12px; + align-items: end; } -.panel.is-highlighted { - animation: - rise-in 0.7s ease both, - preview-pulse 1.1s ease; +.agent-studio-workbench { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(320px, 360px); + grid-template-rows: minmax(0, 1fr) auto; + grid-template-areas: + "reader director" + "rail director"; + gap: 16px; + min-height: clamp(860px, calc(100vh - 120px), 1040px); + align-items: start; } -.paywall-banner { - margin-top: 16px; - padding: 14px 16px; - border-radius: var(--radius-md); - border: 1px solid rgba(165, 68, 47, 0.18); - background: linear-gradient(180deg, rgba(255, 245, 236, 0.96), rgba(250, 233, 224, 0.88)); - color: var(--accent-strong); - line-height: 1.65; +.agent-studio-rail { + grid-area: rail; } -.paywall-banner.is-hidden { - display: none; +.agent-studio-reader { + grid-area: reader; } -.story-feed { - display: flex; - flex-direction: column; - gap: 18px; +.agent-studio-director { + grid-area: director; + height: clamp(860px, calc(100vh - 120px), 1040px); + max-height: calc(100vh - 32px); + position: sticky; + top: 16px; + align-self: start; } -.story-feed-card { - padding: 22px; - border-radius: var(--radius-lg); - border: 1px solid rgba(29, 30, 42, 0.08); - background: linear-gradient(180deg, rgba(255, 251, 244, 0.96), rgba(247, 241, 231, 0.82)); +.agent-studio-rail, +.agent-studio-director { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; + min-height: 0; + overflow: auto; + padding: 16px; + border: 1px solid rgba(224, 235, 230, 0.13); + border-radius: 12px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.065), rgba(255, 255, 255, 0.035)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); } -.story-feed-card.is-active { - border-color: rgba(53, 90, 82, 0.28); - box-shadow: inset 0 0 0 1px rgba(53, 90, 82, 0.12); +.agent-studio-rail { + display: grid; + grid-template-columns: minmax(170px, 0.72fr) minmax(220px, 1fr) minmax(260px, 1.25fr) auto; + gap: 12px; + align-items: stretch; + overflow: hidden; + padding: 12px; } -.story-feed-head h3 { - margin: 0; - font-family: var(--display-font); - font-size: 1.5rem; +.agent-studio-rail-section, +.agent-studio-export-actions { + min-width: 0; } -.story-feed-recap { - margin: 14px 0 0; - color: var(--ink-soft); - line-height: 1.7; +.agent-studio-export-actions { + align-self: end; + justify-content: flex-end; } -.story-feed-body { - margin-top: 16px; - white-space: pre-wrap; - line-height: 1.92; - color: var(--ink); +.agent-studio-work-heading h2 { + margin: 4px 0; + font-size: 1.25rem; + letter-spacing: 0; } -.story-feed-hints { - margin-top: 14px; +.agent-studio-work-heading span, +.agent-studio-route-card span, +.agent-studio-reader-toolbar, +.agent-studio-feedback span { + color: rgba(235, 244, 240, 0.68); } -.story-feed-hints span { - display: inline-flex; - align-items: center; - min-height: 38px; - padding: 0 12px; - border-radius: 999px; - border: 1px solid rgba(53, 90, 82, 0.12); - background: rgba(53, 90, 82, 0.06); - color: var(--jade); - font-size: 0.88rem; +.agent-studio-chapter-list, +.agent-studio-branch-map { + display: grid; + gap: 8px; + max-height: 128px; + overflow: auto; } -.panel-head { - margin-bottom: 18px; +.agent-studio-rail, +.agent-studio-director, +.agent-studio-page, +.agent-studio-choice-grid { + scrollbar-color: rgba(130, 219, 219, 0.46) rgba(255, 255, 255, 0.04); + scrollbar-width: thin; } -.panel-head h2 { - margin: 0; - font-size: 1.3rem; - line-height: 1.2; +.agent-studio-chapter-link, +.agent-studio-route-card, +.agent-studio-choice-card, +.agent-studio-feedback, +.agent-studio-generation-status, +.agent-studio-side-section { + border: 1px solid rgba(224, 235, 230, 0.15); + background: rgba(255, 255, 255, 0.065); + border-radius: 8px; } -.meta-list { - margin: 16px 0 0; +.agent-studio-chapter-link { display: grid; - gap: 12px; + gap: 3px; + text-align: left; + color: inherit; + padding: 10px 12px; } -.meta-list div { - padding-bottom: 12px; - border-bottom: 1px dashed rgba(29, 30, 42, 0.12); +.agent-studio-chapter-link span { + color: rgba(130, 219, 219, 0.78); + font-size: 0.78rem; } -.meta-list div:last-child { - padding-bottom: 0; - border-bottom: 0; +.agent-studio-chapter-link strong { + font-size: 0.92rem; + font-weight: 650; } -.suggested-inputs { - display: flex; - flex-wrap: wrap; - gap: 10px; +.agent-studio-chapter-link.is-active, +.agent-studio-route-card.is-active { + border-color: rgba(130, 219, 219, 0.54); + background: rgba(36, 131, 144, 0.18); } -.suggestion-chip { - text-align: left; - line-height: 1.45; - background: linear-gradient(180deg, rgba(255, 250, 244, 0.9), rgba(247, 239, 229, 0.78)); +.agent-studio-route-card { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + align-items: center; + padding: 12px; } -.state-grid { +.agent-studio-route-card div { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; + gap: 5px; } -.input-label { - display: inline-block; - margin-bottom: 10px; - font-size: 0.92rem; - font-weight: 600; +.agent-studio-reader { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(560px, min(64vh, 680px)) auto auto; + gap: 14px; } -.field-input, -.field-select { - width: 100%; - border-radius: 16px; - border: 1px solid rgba(29, 30, 42, 0.1); - background: rgba(255, 252, 246, 0.98); - color: var(--ink); - padding: 14px 16px; - line-height: 1.5; - margin-bottom: 16px; +.agent-studio-reader-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 42px; + padding: 0 2px; } -textarea { - width: 100%; - min-height: 124px; - border-radius: 18px; - border: 1px solid rgba(29, 30, 42, 0.1); - background: rgba(255, 252, 246, 0.98); - color: var(--ink); - padding: 16px 18px; - line-height: 1.7; - resize: vertical; +.agent-studio-page { + min-height: 560px; + max-height: 680px; + overflow: auto; + padding: clamp(28px, 4vw, 54px) clamp(24px, 5vw, 70px); + border-radius: 12px; + background: linear-gradient(180deg, rgba(255, 252, 240, 0.96), rgba(242, 234, 218, 0.94)); + color: #171a1d; + box-shadow: + inset 0 0 0 1px rgba(30, 38, 41, 0.08), + 0 20px 50px rgba(3, 7, 12, 0.24); + scrollbar-gutter: stable; } -textarea:focus, -input:focus, -select:focus { - outline: 2px solid rgba(165, 68, 47, 0.2); - border-color: rgba(165, 68, 47, 0.35); +.agent-studio-page h2 { + max-width: 760px; + margin: 0 auto 24px; + font-size: clamp(1.7rem, 2.5vw, 2.65rem); + line-height: 1.12; + letter-spacing: 0; } -.best-route { - margin: 0 0 18px; - font-family: var(--mono-font); - font-size: 0.88rem; - line-height: 1.6; - color: var(--jade); +.agent-studio-reader-body { + font-size: 1.08rem; + line-height: 1.95; + max-width: 720px; + margin: 0 auto; } -.rendered-scene { - margin: 18px 0 0; - padding: 22px; - border-radius: var(--radius-lg); - border: 1px solid rgba(29, 30, 42, 0.08); - background: linear-gradient(180deg, rgba(255, 251, 244, 0.94), rgba(247, 241, 231, 0.72)); - white-space: pre-wrap; - line-height: 1.84; +.agent-studio-reader-body p { + margin: 0 0 1.15em; } -.route-preview, -.story-sequence, -.list-stack { - display: flex; - flex-direction: column; - gap: 12px; +.agent-studio-feedback { + display: grid; + gap: 4px; + padding: 12px 14px; } -.empty-state { - color: var(--ink-soft); +.agent-studio-choice-section { + display: grid; + gap: 10px; + min-height: 0; } -.list-card, -.route-line, -.story-sequence-card { - padding: 16px; - border-radius: var(--radius-lg); - border: 1px solid rgba(29, 30, 42, 0.08); - background: var(--panel-strong); +.agent-studio-choice-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + max-height: 190px; + overflow: auto; + padding-right: 2px; } -.list-card.is-active { - border-color: rgba(53, 90, 82, 0.24); - box-shadow: - inset 0 0 0 1px rgba(53, 90, 82, 0.12), - 0 14px 24px rgba(48, 34, 24, 0.06); +.agent-studio-choice-card { + display: grid; + gap: 10px; + align-content: start; + padding: 14px; } -.route-line { - background: - linear-gradient(180deg, rgba(255, 252, 246, 0.96), rgba(248, 242, 233, 0.82)), - linear-gradient(90deg, rgba(53, 90, 82, 0.06), transparent 40%); +.agent-studio-choice-card .primary-action { + width: 100%; + margin-top: auto; + padding-block: 10px; } -.route-line strong { +.agent-studio-choice-main strong { display: block; - margin-bottom: 8px; + margin-bottom: 5px; font-size: 1.02rem; } -.route-line .route-rank { - display: inline-flex; - margin-bottom: 10px; - padding: 6px 10px; - border-radius: 999px; - background: rgba(53, 90, 82, 0.08); - color: var(--jade); - font-size: 0.78rem; - letter-spacing: 0.08em; - text-transform: uppercase; +.agent-studio-choice-main p { + margin: 0; + color: rgba(235, 244, 240, 0.72); + font-size: 0.92rem; + line-height: 1.45; } -.list-card.is-active, -.story-sequence-card.is-active { - border-color: rgba(53, 90, 82, 0.32); - box-shadow: inset 0 0 0 1px rgba(53, 90, 82, 0.14); +.agent-studio-choice-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; } -.list-card-head { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 12px; +.agent-studio-choice-tags span, +.agent-studio-quickbar button { + border: 1px solid rgba(130, 219, 219, 0.22); + background: rgba(15, 42, 48, 0.45); + color: rgba(242, 252, 250, 0.88); + border-radius: 999px; + padding: 5px 9px; + font-size: 0.74rem; } -.list-card-head h3 { - margin: 0; - font-size: 1rem; +.agent-studio-director textarea { + min-height: 170px; + resize: vertical; } -.list-card-score { - font-family: var(--mono-font); - font-size: 0.82rem; - color: var(--jade); +.agent-studio-quickbar { + display: flex; + flex-wrap: wrap; + gap: 8px; } -.list-card-body { - margin: 10px 0 0; - white-space: pre-wrap; - line-height: 1.72; - font-size: 0.92rem; +.agent-studio-quickbar button { + cursor: pointer; } -.story-panel { - overflow: hidden; +.agent-studio-quality { + display: grid; + gap: 8px; } -.story-hero { - position: relative; - min-height: 320px; - border-radius: var(--radius-xl); - background: - radial-gradient(circle at 20% 20%, rgba(245, 206, 109, 0.28), transparent 24%), - radial-gradient(circle at 80% 22%, rgba(53, 90, 82, 0.24), transparent 22%), - linear-gradient(135deg, rgba(165, 68, 47, 0.94), rgba(53, 90, 82, 0.88)); +.agent-studio-quality-item { display: flex; - align-items: flex-end; - padding: 26px; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 9px 0; + border-bottom: 1px solid rgba(224, 235, 230, 0.11); } -.story-hero[data-motif="temptation"] { - background: - radial-gradient(circle at 18% 20%, rgba(246, 209, 159, 0.34), transparent 24%), - radial-gradient(circle at 82% 24%, rgba(102, 49, 94, 0.3), transparent 22%), - linear-gradient(135deg, rgba(95, 49, 79, 0.95), rgba(187, 107, 80, 0.88)); +.agent-studio-quality-item:last-child { + border-bottom: 0; } -.story-hero[data-motif="discovery"] { - background: - radial-gradient(circle at 16% 18%, rgba(236, 219, 176, 0.34), transparent 22%), - radial-gradient(circle at 78% 22%, rgba(86, 116, 160, 0.24), transparent 24%), - linear-gradient(135deg, rgba(37, 56, 95, 0.96), rgba(139, 95, 64, 0.9)); +.agent-studio-side-section { + padding: 14px; } -.story-hero-overlay { - max-width: 62ch; - color: #fff8f2; +.agent-studio-generation-status { + display: grid; + gap: 4px; + padding: 10px 12px; } -.story-hero-overlay h2 { - margin: 0; - font-family: var(--display-font); - font-size: clamp(2rem, 4.2vw, 3.5rem); - line-height: 0.96; +.agent-studio-generation-status strong, +.agent-studio-generation-status span { + display: block; } -.story-caption { - margin: 14px 0 0; - color: rgba(255, 248, 242, 0.86); - line-height: 1.7; +.agent-studio-generation-status span { + color: rgba(235, 244, 240, 0.7); + line-height: 1.5; } -.story-quote { - margin: 18px 0 0; - padding: 18px 20px; - border-left: 3px solid rgba(165, 68, 47, 0.5); - border-radius: 0 18px 18px 0; - background: rgba(255, 249, 240, 0.76); - font-family: var(--display-font); - font-size: 1.2rem; - line-height: 1.55; - color: var(--ink); +.agent-studio-generation-status[data-kind="success"] { + border-color: rgba(108, 211, 151, 0.34); } -.story-beats, -.story-details { - display: flex; - flex-wrap: wrap; - gap: 10px; - margin-top: 16px; +.agent-studio-generation-status[data-kind="error"] { + border-color: rgba(240, 132, 110, 0.44); } -.story-beat, -.story-detail { - display: inline-flex; - align-items: center; - min-height: 38px; - padding: 0 14px; - border-radius: 999px; - border: 1px solid rgba(29, 30, 42, 0.08); - background: rgba(255, 252, 246, 0.92); - color: var(--ink); - font-size: 0.9rem; - line-height: 1.4; -} +@media (max-width: 1179px) { + .agent-studio-workbench { + grid-template-columns: 1fr; + grid-template-areas: + "reader" + "director" + "rail"; + height: auto; + min-height: 0; + } -.story-detail { - min-height: 42px; - border-radius: 16px; -} + .agent-studio-rail { + display: flex; + flex-direction: column; + } -.story-sequence-card { - cursor: pointer; + .agent-studio-rail, + .agent-studio-director { + height: auto; + max-height: none; + position: static; + overflow: visible; + } + + .agent-studio-page { + min-height: min(560px, 72vh); + max-height: min(720px, 72vh); + } + + .agent-studio-choice-grid { + grid-template-columns: 1fr; + max-height: min(440px, 52vh); + overflow: auto; + } } -.story-sequence-card h3 { - margin: 0 0 8px; - font-size: 1rem; +button:focus-visible, +.segment:focus-visible, +.ghost-action:focus-visible, +.primary-action:focus-visible, +.suggestion-chip:focus-visible, +.story-sequence-card:focus-visible, +.world-card button:focus-visible, +.session-card button:focus-visible { + outline: 3px solid rgba(182, 141, 64, 0.44); + outline-offset: 2px; } -@keyframes rise-in { - from { - opacity: 0; - transform: translateY(16px); +@media (max-width: 1320px) { + .shell-auth-hero, + .topbar-hero, + .landing-hero, + .landing-grid { + grid-template-columns: 1fr; } - to { - opacity: 1; - transform: translateY(0); + + .shell-subnav { + flex-direction: column; + align-items: stretch; } -} -@keyframes preview-pulse { - 0% { - box-shadow: var(--shadow), 0 0 0 0 rgba(165, 68, 47, 0.26); + .app-shell[data-product="author"] .topbar-hero { + grid-template-columns: 1fr; } - 55% { - box-shadow: var(--shadow), 0 0 0 14px rgba(165, 68, 47, 0); + + .app-shell[data-product="author"] .shell-toolbar { + grid-template-columns: 1fr; } - 100% { - box-shadow: var(--shadow), 0 0 0 0 rgba(165, 68, 47, 0); + + .author-editor-hero-head, + .author-editor-grid { + grid-template-columns: 1fr; } -} -@media (max-width: 1320px) { - .topbar { + .author-steering-grid { grid-template-columns: 1fr; } - .topbar-controls { + .agent-studio-start { grid-template-columns: 1fr; } +} - .featured-world { +@media (max-width: 960px) { + .app-shell[data-product="reader"] #reader-shell { grid-template-columns: 1fr; } - .workspace, - .app-shell[data-view="backstage"] .workspace, - .app-shell[data-view="experience"] .workspace, - .app-shell[data-view="storybook"] .workspace { + .reader-shell-v2__hero, + .reader-shell-v2__grid { grid-template-columns: 1fr; } - .app-shell[data-view="backstage"] .rail-right, - .app-shell[data-view="experience"] .rail-right, - .app-shell[data-view="storybook"] .rail-right { - display: flex; + .app-shell[data-product="reader"] #backstage-view { + top: auto; + right: 12px; + left: 12px; + bottom: 12px; + width: auto; + max-height: min(78vh, 760px); + } + + .toast-stack { + right: 12px; + left: 12px; + width: auto; + } + + .shell-auth-panel { + padding: 18px; + } + + .author-editor-hero-status { + grid-template-columns: 1fr 1fr; } } @media (max-width: 780px) { - .app-shell { - width: min(calc(100vw - 18px), var(--max-width)); - padding: 12px 0 28px; + .topbar { + padding: 20px; + border-radius: 28px; } - .topbar, - .panel { - padding: 18px; + .landing-actions, + .featured-world-actions, + .product-workspace .composer-actions { + flex-direction: column; } - .state-grid, - .status-grid { + .author-form-grid, + .author-settings-summary-grid, + .author-simulate-work-grid, + .author-workspace-summary-grid, + .author-workflow-grid, + .author-detail-grid { + grid-template-columns: 1fr; + } + + .author-sticky-actions { + position: static; + bottom: auto; + } + + .status-grid--compact, + .landing-status .status-grid { grid-template-columns: 1fr 1fr; } - .world-gallery, - .session-library { + .author-editor-actions, + .author-editor-main-actions { + flex-direction: column; + } + + .author-editor-hero-status { grid-template-columns: 1fr; } - .story-hero { - min-height: 240px; + .app-shell[data-product="author"][data-author-workspace="studio"] { + width: min(calc(100vw - 18px), var(--max-width)); + padding-top: 10px; + } + + .app-shell[data-product="author"][data-author-workspace="studio"] .topbar { + padding: 10px; + border-radius: 18px; + } + + .app-shell[data-product="author"][data-author-workspace="studio"] .brand-block, + .app-shell[data-product="author"][data-author-workspace="studio"] .shell-subnav > div:first-child { + display: none; + } + + .app-shell[data-product="author"][data-author-workspace="studio"] .topbar-hero, + .app-shell[data-product="author"][data-author-workspace="studio"] .shell-toolbar { + grid-template-columns: 1fr; + gap: 8px; + } + + .agent-studio-panel { + padding: 12px; + } + + .agent-studio-workbench { + grid-template-columns: 1fr; + grid-template-areas: + "reader" + "director" + "rail"; + gap: 14px; + height: auto; + min-height: 0; + } + + .agent-studio-rail, + .agent-studio-director { + overflow: visible; + padding: 14px; + } + + .agent-studio-reader { + grid-template-rows: auto auto auto auto; + } + + .agent-studio-reader-toolbar { + flex-wrap: wrap; + } + + .agent-studio-page { + max-height: min(720px, 72vh); + padding: 24px 20px; + } + + .agent-studio-choice-grid { + max-height: min(360px, 42vh); + } + + .agent-studio-page h2 { + font-size: 1.55rem; + } + + .agent-studio-reader-body { + font-size: 1rem; + line-height: 1.85; + } + + .agent-studio-field-grid, + .agent-studio-choice-grid { + grid-template-columns: 1fr; + } + + .app-shell[data-product="reader"] #reader-shell .rail-left { + order: 2; + } + + .app-shell[data-product="reader"] #reader-shell .stage { + order: 1; } } diff --git a/src/narrativeos/web/ui_shared.js b/src/narrativeos/web/ui_shared.js new file mode 100644 index 0000000..5cf9c8f --- /dev/null +++ b/src/narrativeos/web/ui_shared.js @@ -0,0 +1,467 @@ +// UI and transport helpers shared across shell, reader, author, and ops runtimes. + +var UIShared = (() => { + const dom = ShellDOM; + class ApiError extends Error { + constructor({ status, message, detail = null, code = null, actionHint = null, retryable = false, source = "api" }) { + super(message || "请求失败"); + this.name = "ApiError"; + this.status = status; + this.detail = detail; + this.code = code; + this.action_hint = actionHint; + this.retryable = retryable; + this.source = source; + } + } + + function safeText(value, fallback = "-") { + if (value === undefined || value === null || value === "") return fallback; + return String(value); + } + + const DISPLAY_REPLACEMENTS = [ + [/Reader Workspace/g, "阅读区域"], + [/Author Workspace/g, "创作区域"], + [/Ops Workspace/g, "运营区域"], + [/Review Queue/g, "审核队列"], + [/Release Workspace/g, "发布台"], + [/Content Release Workspace/g, "内容发布台"], + [/Dashboard/g, "总览"], + [/Account Investigation/g, "账户排查"], + [/Alerts & Governance/g, "告警治理"], + [/Learned Dashboard/g, "学习层总览"], + [/Learned Impact/g, "学习层影响"], + [/Learned Cadence/g, "学习层节奏"], + [/Learned Data Ops/g, "学习数据运营"], + [/Shadow Candidate Compare/g, "影子候选对照"], + [/Evaluator Promotion Gate/g, "评估器晋升门"], + [/Reranker Promotion Gate/g, "重排器晋升门"], + [/Human Review Coverage/g, "人工审阅覆盖"], + [/Review Backlog/g, "审核待办"], + [/Quick Capture Review/g, "快速补录审阅"], + [/Last Action Impact/g, "最近动作影响"], + [/Review History/g, "审核历史"], + [/Issue Focus Queue/g, "问题队列"], + [/Issue \/ Module Drill-down/g, "问题 / 模块拆解"], + [/Weakest Chapters/g, "重点问题章节"], + [/Chapter Drill-down/g, "章节拆解"], + [/Longform Plan Status/g, "长篇规划状态"], + [/Promise Ledger Summary/g, "承诺账本摘要"], + [/Selected Promise/g, "当前承诺"], + [/Open Promises/g, "未收回承诺"], + [/Recently Closed/g, "最近回收"], + [/Collaboration Summary/g, "协作摘要"], + [/Assignee Queues/g, "处理人队列"], + [/Draft Watchers/g, "关注人"], + [/Latest Notifications/g, "最新通知"], + [/Reviewer Inbox Summary/g, "审阅收件箱摘要"], + [/Inbox by Draft/g, "按草稿查看收件箱"], + [/Continuity Work Card/g, "连续性工作卡"], + [/Compare Work Card/g, "对照工作卡"], + [/Chapter Task Work Card/g, "章节任务工作卡"], + [/Simulation freshness/g, "诊断新鲜度"], + [/\bthread_assigned\b/g, "分配给我"], + [/\bthread_mentioned\b/g, "提到我"], + [/\bthread_updated\b/g, "线程更新"], + [/\bapproval_requested\b/g, "收到审阅请求"], + [/\bapproval_decision\b/g, "审阅结果"], + [/\badvance_plot\b/g, "推进主线"], + [/\badvance_relationship\b/g, "推进关系"], + [/\bresolve_promise\b/g, "回收承诺"], + [/\bexpand_world\b/g, "扩展世界"], + [/\bpace_breath\b/g, "节奏缓冲"], + [/\bdeliver_climax\b/g, "交付高潮"], + [/\bplan_payoff\b/g, "计划回收"], + [/\bresolved_intentional\b/g, "已刻意收束"], + [/\baccepted_tradeoff\b/g, "接受折中"], + [/\bneeds_rewrite\b/g, "需要重写"], + [/\bintentional\b/g, "刻意保留"], + [/\bwatch\b/g, "持续关注"], + [/\bdefer\b/g, "暂缓处理"], + [/\bescalate\b/g, "升级处理"], + [/\brequested\b/g, "已发起"], + [/\bsubmitted\b/g, "已提交"], + [/\bapproved\b/g, "已通过"], + [/\bresolved\b/g, "已解决"], + [/\bdismissed\b/g, "已关闭"], + [/\bescalated\b/g, "已升级"], + [/\bpublished\b/g, "已发布"], + [/\brolled_back\b/g, "已回滚"], + [/\bpending\b/g, "待处理"], + [/\bactive\b/g, "处理中"], + [/\bunread\b/g, "未读"], + [/\bopen\b/g, "待处理"], + [/\bnone\b/g, "无"], + [/\byes\b/g, "是"], + [/\bno\b/g, "否"], + [/\bdraft\b/g, "草稿"], + [/\brevision\b/g, "版本"], + [/\bdiff\b/g, "差异"], + [/\bcompare\b/g, "对照"], + [/\bsimulation\b/g, "诊断"], + [/\bcheckpoint\b/g, "检查点"], + [/\btask\b/g, "任务"], + [/\bpromise\b/g, "承诺"], + [/\bworkflow\b/g, "工作流"], + [/\bworld_version\b/g, "世界版本"], + [/\bworld\b/g, "世界"], + [/\baccount\b/g, "账户"], + [/\breviewer\b/g, "审阅人"], + [/\bapproval\b/g, "审阅"], + [/\bprovider\b/g, "通道"], + [/\bstatus\b/g, "状态"], + [/\bsummary\b/g, "摘要"], + [/\bnext action\b/gi, "下一步动作"], + ]; + + function localizeDisplayText(value) { + if (value === undefined || value === null) return ""; + let text = String(value); + for (const [pattern, replacement] of DISPLAY_REPLACEMENTS) { + text = text.replace(pattern, replacement); + } + return text; + } + + function clearStatusBanner() { + if (!dom.shellStatusBanner) return; + dom.shellStatusBanner.textContent = ""; + dom.shellStatusBanner.className = "status-banner is-hidden"; + } + + function sanitizePublicMessage(message, kind = "info") { + const text = String(message || ""); + if (shellState?.debug) return text; + const engineeringPattern = /TypeError|ReferenceError|SyntaxError|is not a function|HTTP \d{3}|Traceback|stack trace|actor_id|session_id|world_id|provider|token|JSON|checkout_session_id/i; + if (!engineeringPattern.test(text)) return text; + if (kind === "error") return "页面正在更新,请刷新后重试。"; + if (kind === "warning") return "当前操作暂时不可用,请稍后重试。"; + return "页面信息已更新。"; + } + + function showStatusBanner(message, kind = "info") { + if (!dom.shellStatusBanner) return; + const safeMessage = sanitizePublicMessage(message, kind); + dom.shellStatusBanner.textContent = safeMessage; + dom.shellStatusBanner.className = `status-banner status-banner--${kind}`; + if (dom.shellLiveRegion) { + dom.shellLiveRegion.textContent = safeMessage; + } + } + + function emitToast(message, kind = "info") { + if (!dom.shellToastStack) return; + const toast = document.createElement("div"); + toast.className = `toast toast--${kind}`; + toast.textContent = sanitizePublicMessage(message, kind); + dom.shellToastStack.appendChild(toast); + window.setTimeout(() => { + toast.classList.add("toast--leaving"); + window.setTimeout(() => toast.remove(), 220); + }, 2800); + } + + function reportUiMessage(message, kind = "info") { + showStatusBanner(message, kind); + emitToast(message, kind); + } + + function installNonBlockingAlerts() { + if (typeof window === "undefined" || window.__narrativeosAlertWrapped) return; + window.alert = (message) => { + reportUiMessage(String(message), "error"); + }; + window.__narrativeosAlertWrapped = true; + } + + function normalizeApiResponseText(text) { + if (!text) return {}; + try { + return JSON.parse(text); + } catch (_error) { + return text; + } + } + + async function api(path, options = {}) { + const hasExplicitAuthorization = Boolean(options.headers && Object.keys(options.headers).some((key) => key.toLowerCase() === "authorization")); + const isBridgeResolve = path.startsWith("/v1/auth/admin-view-bridge/resolve"); + const shouldAttachAdminBridge = + Boolean(shellState.activeProduct === "ops" && shellState.adminViewBridgeToken) && + path.startsWith("/v1/ops"); + const shouldAttachAuthorToken = + !shouldAttachAdminBridge && + !isBridgeResolve && + Boolean(authorState.authorAuthSession?.accessToken) && + ( + path.startsWith("/v1/author") || + path.startsWith("/v1/ops") || + (path.startsWith("/v1/auth") && !path.startsWith("/v1/auth/login") && !path.startsWith("/v1/auth/register")) + ); + const shouldAttachReaderToken = + !hasExplicitAuthorization && + !isBridgeResolve && + Boolean(readerState.readerAuthSession?.accessToken) && + ( + path.startsWith("/v1/reader") || + path.startsWith("/v1/sessions") || + (path.startsWith("/v1/auth") && !path.startsWith("/v1/auth/login") && !path.startsWith("/v1/auth/register")) + ); + const response = await fetch(path, { + headers: { + "Content-Type": "application/json", + ...(shouldAttachAdminBridge ? { "X-NarrativeOS-Admin-Bridge": shellState.adminViewBridgeToken } : {}), + ...(shouldAttachAuthorToken ? { Authorization: `Bearer ${authorState.authorAuthSession.accessToken}` } : {}), + ...(shouldAttachReaderToken ? { Authorization: `Bearer ${readerState.readerAuthSession.accessToken}` } : {}), + ...(options.headers || {}), + }, + ...options, + }); + if (!response.ok) { + const raw = await response.text(); + const payload = normalizeApiResponseText(raw); + const detailPayload = + typeof payload === "object" && payload !== null + ? payload.detail !== undefined + ? payload.detail + : payload.message !== undefined + ? payload.message + : payload + : raw || response.statusText || "请求失败"; + const detail = + typeof detailPayload === "string" + ? detailPayload + : JSON.stringify(detailPayload); + throw new ApiError({ + status: response.status, + message: detail, + detail: payload, + code: typeof payload === "object" && payload !== null ? payload.code || null : null, + actionHint: typeof payload === "object" && payload !== null ? payload.action_hint || null : null, + retryable: typeof payload === "object" && payload !== null ? Boolean(payload.retryable) : response.status >= 500, + source: path, + }); + } + const raw = await response.text(); + const payload = normalizeApiResponseText(raw); + return payload && typeof payload === "object" ? payload : {}; + } + + function parseErrorDetail(error) { + if (error?.detail && typeof error.detail === "object") { + return error.detail; + } + try { + return JSON.parse(error.message); + } catch (_error) { + return null; + } + } + + function describeAuthError(error, fallbackMessage = "当前操作暂时不可用,请稍后重试。") { + const detail = parseErrorDetail(error) || {}; + const code = String(detail.code || "").trim(); + const reason = String(detail.reason || "").trim(); + if (detail.account_created && detail.can_retry_send) { + return "账号已经创建,但验证邮件暂时没有发出。你可以稍后重新发送验证邮件。"; + } + if (code === "auth_email_unverified") { + return "你的邮箱还未验证。请先验证邮箱,再登录;如果没收到邮件,可以重新发送验证邮件。"; + } + if (code === "auth_register_delivery_failed" || code === "auth_verification_delivery_failed" || code === "auth_password_reset_delivery_failed") { + if (reason === "test_mode_external_recipient_blocked") { + return "当前邮件系统仍处于测试模式,只能向测试邮箱发送邮件。请先使用 Resend 测试邮箱,或等发件域验证完成后再试。"; + } + if (reason === "domain_not_verified") { + return "发件域还没有完成验证,暂时不能向真实邮箱发送邮件。请先完成发件域验证后再试。"; + } + if (reason === "provider_not_configured") { + return "邮件服务配置还没有完成,暂时无法发送邮件。"; + } + if (reason === "provider_network_error") { + return "邮件服务暂时不可用,请稍后重试。"; + } + if (reason === "provider_rejected") { + return "邮件服务拒绝了这次发送请求,请检查发件配置后重试。"; + } + if (reason === "db_write_failed" || reason === "token_issue_failed") { + return "账户信息已收到,但系统暂时没能完成邮件流程,请稍后重试。"; + } + } + if (code === "auth_verification_invalid" && reason === "verification_resend_cooldown") { + return detail.next_allowed_at + ? `验证邮件刚发出不久,请在 ${formatTimestamp(detail.next_allowed_at)} 之后再试。` + : "验证邮件刚发出不久,请稍等再试。"; + } + if (code === "auth_verification_token_invalid") { + if (reason === "auth_flow_token_consumed") { + return "这个验证链接已经用过了。"; + } + if (reason === "auth_flow_token_superseded") { + return "这个验证链接已经被新的邮件替换了,请使用最新一封邮件里的链接。"; + } + if (reason === "auth_flow_token_expired") { + return "这个验证链接已经过期,请重新发送验证邮件。"; + } + } + if (code === "auth_password_reset_token_invalid") { + if (reason === "auth_flow_token_consumed") { + return "这个重置链接已经用过了,请重新申请一次密码重置。"; + } + if (reason === "auth_flow_token_superseded") { + return "这个重置链接已经被新的邮件替换了,请使用最新一封邮件里的链接。"; + } + if (reason === "auth_flow_token_expired") { + return "这个重置链接已经过期,请重新申请一次密码重置。"; + } + } + if (code === "auth_login_failed" && reason === "invalid_credentials") { + return "邮箱或密码不正确。"; + } + if (code === "auth_identity_missing" || (code === "auth_token_missing" && reason.includes("unknown_auth_identity"))) { + return "这个账号不存在。"; + } + return fallbackMessage; + } + + function setBusy(button, busyLabel) { + const previous = button.textContent; + button.disabled = true; + button.textContent = busyLabel; + return () => { + button.disabled = false; + button.textContent = previous; + }; + } + + function clearNode(node, emptyText = "") { + node.innerHTML = ""; + if (emptyText) { + node.classList.add("empty-state"); + node.textContent = localizeDisplayText(emptyText); + } else { + node.classList.remove("empty-state"); + } + } + + function createListCard({ title, score = "", body = "", active = false }) { + const card = document.createElement("article"); + card.className = "list-card"; + if (active) { + card.classList.add("is-active"); + } + card.innerHTML = ` +
+

${localizeDisplayText(title)}

+ ${localizeDisplayText(score)} +
+

${localizeDisplayText(body)}

+ `; + return card; + } + + function formatTimestamp(value) { + if (!value) return "未知时间"; + try { + return new Date(value).toLocaleString("zh-CN"); + } catch (_error) { + return value; + } + } + + function downloadJsonFile(filename, payload) { + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); + } + + function downloadTextFile(filename, content, contentType = "text/plain") { + const blob = new Blob([String(content || "")], { type: contentType }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); + } + + function downloadBase64File(filename, base64Content, contentType = "application/octet-stream") { + const binary = atob(String(base64Content || "")); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + const blob = new Blob([bytes], { type: contentType }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); + } + + function formatPercent(value) { + return `${(Number(value || 0) * 100).toFixed(0)}%`; + } + + function parseIssueCodes(value) { + return String(value || "") + .split(/[\s,,]+/) + .map((item) => item.trim()) + .filter(Boolean); + } + + function parseTagList(value) { + return String(value || "") + .split(/[\s,,]+/) + .map((item) => item.trim()) + .filter(Boolean); + } + + function parseMaybeJson(value) { + if (typeof value !== "string") return value; + try { + return JSON.parse(value); + } catch (_error) { + return value; + } + } + + return { + ApiError, + safeText, + clearStatusBanner, + sanitizePublicMessage, + showStatusBanner, + emitToast, + reportUiMessage, + installNonBlockingAlerts, + normalizeApiResponseText, + api, + parseErrorDetail, + describeAuthError, + setBusy, + clearNode, + localizeDisplayText, + createListCard, + formatTimestamp, + downloadJsonFile, + downloadTextFile, + downloadBase64File, + formatPercent, + parseIssueCodes, + parseTagList, + parseMaybeJson, + }; +})(); diff --git a/src/narrativeos/web/workspace_layout_runtime.js b/src/narrativeos/web/workspace_layout_runtime.js new file mode 100644 index 0000000..a4cddf4 --- /dev/null +++ b/src/narrativeos/web/workspace_layout_runtime.js @@ -0,0 +1,262 @@ +// Shell workspace layout orchestration extracted from app.js. + +var WorkspaceLayoutRuntime = (() => { + const shellDom = ShellDOM; + const readerDom = ReaderDOM; + const { reportUiMessage, clearNode } = UIShared; + + function activeReaderShellRuntime() { + return (typeof ReaderShellV2 === "object" && ReaderShellV2) || ReaderRuntime; + } + + const AUTHOR_WORKSPACES = [ + { key: "studio", label: "Studio", description: "设定目标,阅读章节,用导演意图和路线选择推进作品。" }, + { key: "overview", label: "总览", description: "看当前草稿、当前阶段、阻塞与推荐动作。" }, + { key: "brief", label: "起稿", description: "定义题材、人物关系与故事起点。" }, + { key: "draft", label: "创作台", description: "编辑人物、场景、长篇规划与风格设定。" }, + { key: "simulate", label: "问题诊断", description: "查看校验、章节问题与连续性诊断结果。" }, + { key: "review", label: "送审", description: "查看最近修改、章节对照与送审证据。" }, + { key: "settings", label: "账户协作", description: "管理登录、通知与协作收件箱。" }, + ]; + + const OPS_WORKSPACES = [ + { key: "dashboard", label: "总览", description: "跨域分诊,优先看未分派、阻塞与超时风险。" }, + { key: "review", label: "统一审阅台", description: "按内容发布、治理、运行与支持四条线统一审阅。" }, + { key: "account", label: "账户排查", description: "排查账户、权益、钱包、工单与调查。" }, + { key: "release", label: "发布台", description: "集中处理发布、清单、回滚与版本状态。" }, + { key: "alerts", label: "告警治理", description: "处理告警、治理个案与审计轨迹。" }, + { key: "infra", label: "模型与基础设施", description: "查看模型、任务、运行手册与观测数据。" }, + ]; + + const CUSTOMER_WORKSPACES = [ + { key: "overview", label: "账户总览", description: "查看客户账户生命周期、套餐、限额和结算资料。" }, + ]; + + const AUTHOR_WORKSPACE_PANEL_ORDER = { + studio: [24], + overview: [0, 1, 6, 5], + brief: [4], + draft: [13, 14, 15, 16, 17, 18, 20, 22, 23], + simulate: [8, 7, 21], + review: [9, 10, 11, 19], + settings: [2, 3, 12], + }; + + const OPS_WORKSPACE_PANEL_ORDER = { + dashboard: [0, 1], + review: [2, 3, 15, 16], + account: [5, 6, 7, 8, 11, 12], + release: [4], + alerts: [9, 10], + infra: [13, 14, 17, 18, 19, 20, 21], + }; + + const CUSTOMER_WORKSPACE_PANEL_ORDER = { + overview: [0, 1], + }; + + function createSubnavButton({ label, active = false, onClick }) { + const button = document.createElement("button"); + button.className = `segment${active ? " is-active" : ""}`; + button.type = "button"; + button.textContent = label; + button.addEventListener("click", async () => { + try { + await onClick(); + } catch (error) { + reportUiMessage(`切换工作区失败:${error.message}`, "error"); + } + }); + return button; + } + + function setReaderWorkspace(workspace, options = {}) { + shellState.readerWorkspace = workspace; + if (workspace === "landing") { + readerState.activeView = "experience"; + } + if (!options.silent) { + ShellStatusRuntime.syncProductMode(); + } + } + + function setAuthorWorkspace(workspace, options = {}) { + shellState.authorWorkspace = workspace; + if (!options.silent) { + ShellStatusRuntime.syncProductMode(); + } + } + + function setCustomerWorkspace(workspace, options = {}) { + shellState.customerWorkspace = workspace; + if (!options.silent) { + ShellStatusRuntime.syncProductMode(); + } + } + + function setOpsWorkspace(workspace, options = {}) { + shellState.opsWorkspace = workspace; + if (!options.silent) { + ShellStatusRuntime.syncProductMode(); + } + } + + function revealWorkspaceForNode(node) { + const authorWorkspace = node?.closest?.(".product-workspace[data-product='author']"); + if (authorWorkspace) { + setAuthorWorkspace(authorWorkspace.dataset.workspace, { silent: true }); + } + const opsWorkspace = node?.closest?.(".product-workspace[data-product='ops']"); + if (opsWorkspace) { + setOpsWorkspace(opsWorkspace.dataset.workspace, { silent: true }); + } + const customerWorkspace = node?.closest?.(".product-workspace[data-product='customer']"); + if (customerWorkspace) { + setCustomerWorkspace(customerWorkspace.dataset.workspace, { silent: true }); + } + } + + function installScrollWorkspaceBridge() { + if (typeof Element === "undefined" || Element.prototype.__narrativeosScrollWrapped) return; + const nativeScrollIntoView = Element.prototype.scrollIntoView; + Element.prototype.scrollIntoView = function patchedScrollIntoView(...args) { + revealWorkspaceForNode(this); + ShellStatusRuntime.syncProductMode(); + return nativeScrollIntoView.apply(this, args); + }; + Element.prototype.__narrativeosScrollWrapped = true; + } + + function buildGuidedWorkspaces(shellEl, product, groups, orderMap) { + if (!shellEl || shellEl.dataset.guidedWorkspaces === "true") return; + const stage = shellEl.querySelector(":scope > .stage"); + if (!stage) return; + const panels = [...stage.children].filter((node) => node.matches("section.panel")); + const stack = document.createElement("div"); + stack.className = "workspace-stack"; + stack.dataset.product = product; + for (const group of groups) { + const workspace = document.createElement("section"); + workspace.className = "product-workspace"; + workspace.dataset.product = product; + workspace.dataset.workspace = group.key; + for (const index of orderMap[group.key] || []) { + const panel = panels[index]; + if (panel) { + workspace.appendChild(panel); + } + } + stack.appendChild(workspace); + } + stage.replaceWith(stack); + shellEl.dataset.guidedWorkspaces = "true"; + } + + function initializeGuidedWorkspaces() { + if (shellState.initializedGuidedWorkspaces) return; + buildGuidedWorkspaces(shellDom.authorShell, "author", AUTHOR_WORKSPACES, AUTHOR_WORKSPACE_PANEL_ORDER); + buildGuidedWorkspaces(shellDom.customerShell, "customer", CUSTOMER_WORKSPACES, CUSTOMER_WORKSPACE_PANEL_ORDER); + buildGuidedWorkspaces(shellDom.opsShell, "ops", OPS_WORKSPACES, OPS_WORKSPACE_PANEL_ORDER); + shellState.initializedGuidedWorkspaces = true; + } + + function syncWorkspaceStacks() { + document.querySelectorAll(".product-workspace").forEach((workspace) => { + const product = workspace.dataset.product; + const key = workspace.dataset.workspace; + const active = + (product === "author" && key === shellState.authorWorkspace) || + (product === "customer" && key === shellState.customerWorkspace) || + (product === "ops" && key === shellState.opsWorkspace); + workspace.classList.toggle("is-hidden", !active); + }); + } + + function renderProductSubnav() { + if (!shellDom.productSubnavActions) return; + clearNode(shellDom.productSubnavActions); + let items = []; + if (shellState.activeProduct === "reader") { + shellDom.productSubnavLabel.textContent = "阅读区域"; + shellDom.productSubnavDescription.textContent = shellState.readerWorkspace === "read" + ? "进入阅读后,可以在沉浸、图文与幕后之间切换,但默认只把故事本身放在正中央。" + : "先从书架进入一段旅程,再决定从哪个世界开场。"; + if (shellState.readerWorkspace === "read" && (readerState.currentBundle || readerState.sessionId)) { + items = [ + { label: "返回书架", active: false, onClick: () => setReaderWorkspace("landing") }, + { label: "沉浸阅读", active: readerState.activeView === "experience", onClick: () => { readerState.activeView = "experience"; ShellStatusRuntime.syncViewMode(); activeReaderShellRuntime()?.refresh?.(); } }, + { label: "图文阅读", active: readerState.activeView === "storybook", onClick: () => { readerState.activeView = "storybook"; ShellStatusRuntime.syncViewMode(); activeReaderShellRuntime()?.renderStorybook?.(); } }, + { label: "幕后档案", active: readerState.activeView === "backstage", onClick: () => { readerState.activeView = "backstage"; ShellStatusRuntime.syncViewMode(); activeReaderShellRuntime()?.renderBackstage?.(); } }, + ]; + } else { + items = [ + { label: "继续上次旅程", active: false, onClick: () => readerDom.readerJumpSessions?.click() }, + { label: "浏览世界", active: false, onClick: () => readerDom.readerJumpWorlds?.click() }, + { label: "开始新旅程", active: false, onClick: () => readerDom.readerStartCurrentWorld?.click() }, + ]; + } + } else if (shellState.activeProduct === "author") { + shellDom.productSubnavLabel.textContent = "创作区域"; + const active = AUTHOR_WORKSPACES.find((item) => item.key === shellState.authorWorkspace); + shellDom.productSubnavDescription.textContent = active?.description || "按作者工作流逐步展开。"; + items = AUTHOR_WORKSPACES.map((item) => ({ + label: item.label, + active: shellState.authorWorkspace === item.key, + onClick: async () => { + setAuthorWorkspace(item.key, { silent: true }); + ShellStatusRuntime.syncProductMode(); + await AuthorWorkspaceRuntime.refreshAuthorSurface(); + }, + })); + } else if (shellState.activeProduct === "customer") { + shellDom.productSubnavLabel.textContent = "客户区域"; + const active = CUSTOMER_WORKSPACES.find((item) => item.key === shellState.customerWorkspace); + shellDom.productSubnavDescription.textContent = active?.description || "按客户生命周期查看当前套餐、限额与结算资料。"; + items = CUSTOMER_WORKSPACES.map((item) => ({ + label: item.label, + active: shellState.customerWorkspace === item.key, + onClick: async () => { + setCustomerWorkspace(item.key, { silent: true }); + ShellStatusRuntime.syncProductMode(); + await CustomerWorkspaceRuntime.refreshCustomerSurface(); + }, + })); + } else { + shellDom.productSubnavLabel.textContent = "运营区域"; + const active = OPS_WORKSPACES.find((item) => item.key === shellState.opsWorkspace); + shellDom.productSubnavDescription.textContent = active?.description || "按运营任务进入对应工作区。"; + items = OPS_WORKSPACES.map((item) => ({ + label: item.label, + active: shellState.opsWorkspace === item.key, + onClick: async () => { + setOpsWorkspace(item.key, { silent: true }); + ShellStatusRuntime.syncProductMode(); + if (item.key === "review") await OpsRefreshRuntime.refreshOpsReleaseFlow(); + if (item.key === "account") await OpsRefreshRuntime.refreshOpsAccountFlow(); + if (item.key === "alerts") await OpsRefreshRuntime.refreshOpsSurface({ scopes: ["alerts", "navigation"] }); + if (item.key === "infra") await OpsRefreshRuntime.refreshOpsSurface({ scopes: ["learned", "jobs", "runtime", "navigation"] }); + }, + })); + } + items.forEach((item) => shellDom.productSubnavActions.appendChild(createSubnavButton(item))); + } + + return { + AUTHOR_WORKSPACES, + CUSTOMER_WORKSPACES, + OPS_WORKSPACES, + AUTHOR_WORKSPACE_PANEL_ORDER, + OPS_WORKSPACE_PANEL_ORDER, + createSubnavButton, + setReaderWorkspace, + setAuthorWorkspace, + setCustomerWorkspace, + setOpsWorkspace, + revealWorkspaceForNode, + installScrollWorkspaceBridge, + buildGuidedWorkspaces, + initializeGuidedWorkspaces, + syncWorkspaceStacks, + renderProductSubnav, + }; +})(); 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/test_agent_studio_interactive_workbench.py b/tests/test_agent_studio_interactive_workbench.py new file mode 100644 index 0000000..317d895 --- /dev/null +++ b/tests/test_agent_studio_interactive_workbench.py @@ -0,0 +1,239 @@ +from pathlib import Path +import shutil +import subprocess + +from fastapi.testclient import TestClient + +from src.narrativeos.api import create_app +from src.narrativeos.models import NarrativeState +from src.narrativeos.repository import SQLAlchemyRepository +from src.narrativeos.services.choice_semantics import build_choice_impacts + + +ROOT = Path(__file__).resolve().parents[1] + + +def _auth_headers(client: TestClient, *, actor_id: str, actor_role: str = "author", password: str = "secret123") -> dict[str, str]: + client.post( + "/v1/auth/register", + json={ + "actor_id": actor_id, + "actor_role": actor_role, + "password": password, + "account_id": actor_id, + }, + ) + login = client.post("/v1/auth/login", json={"actor_id": actor_id, "password": password}) + assert login.status_code == 200 + return {"Authorization": f"Bearer {login.json()['token']['access_token']}"} + + +def _state(chapter_index: int = 1) -> dict: + return NarrativeState.from_dict( + { + "state_id": f"agent_studio_state_{chapter_index}", + "world_id": "agent_studio_world", + "turn_index": chapter_index, + "story_phase": "setup", + "chapter_index": chapter_index, + "min_end_turn": 8, + "fate_pressure": 0.0, + "karmic_weather": {}, + "unresolved_debts": [], + "world_facts": [], + "timeline": [], + "characters": {}, + "relationship_graph": [], + "open_promises": [], + "tension": 0.3, + "themes": {}, + "player_intent": {}, + "recent_scene_functions": [], + "visited_event_ids": [], + "route_fingerprint": [], + "rating_ceiling": "PG13", + } + ).to_dict() + + +def test_choice_semantics_are_product_language_and_keep_choice_ids(): + impacts = build_choice_impacts( + ["追查证据", "保护证人", "隐藏真相"], + chapter_index=13, + ) + + assert [item["choice_id"] for item in impacts] == ["choice_13_1", "choice_13_2", "choice_13_3"] + assert impacts[0]["label"] == "追查证据" + assert impacts[0]["risk_level"] == "高" + assert impacts[1]["relationship"] == "信任" + assert impacts[2]["mystery"] == "加深" + assert all("Q0" not in str(item) for item in impacts) + + +def test_route_choice_history_repository_round_trips(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "route_choices.db")) + + saved = repository.save_route_choice( + session_id="studio_session", + chapter_id="chapter_studio_session_12", + choice_id="choice_12_2", + payload_json={ + "choice_id": "choice_12_2", + "selected_choice": {"label": "保护证人"}, + "director_intent": "增强人物关系", + }, + ) + history = repository.list_route_choices(session_id="studio_session") + + assert saved["choice_id"] == "choice_12_2" + assert len(history) == 1 + assert history[0]["payload_json"]["selected_choice"]["label"] == "保护证人" + + +def test_author_work_nosbook_export_active_route_contains_branch_map_and_choice_history(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "nosbook_export.db")) + app = create_app(repository=repository) + client = TestClient(app) + headers = _auth_headers(client, actor_id="agent_studio_author") + + draft = app.state.authoring_service.create_draft_from_brief( + { + "genre_preset": "urban_mystery", + "world_title": "Agent Studio Export", + "lead_name": "林澈", + "counterpart_name": "沈知", + "core_premise": "验证本地创作工作台导出主线。", + "life_theme": "信任与隐瞒", + "author_id": "agent_studio_author", + "account_id": "agent_studio_author", + } + ) + work = app.state.author_work_service.create_work( + world_version_id=draft["world_version_id"], + account_id="agent_studio_author", + ) + repository.save_author_work_chapter( + { + "work_id": work["work_id"], + "chapter_index": 1, + "chapter_title": "第 1 章 · 雾港", + "body": "雾从码头压下来。\n\n林澈决定先保护证人。", + "summary": "主角做出第一步选择。", + "choices_json": ["追查证据", "保护证人", "隐藏真相"], + "state_snapshot_json": _state(1), + } + ) + branch = app.state.author_work_service.create_branch( + work_id=work["work_id"], + source_chapter_index=1, + label="路线 A:保护证人", + choice_source="保护证人", + steering_directive={"current_user_intent": "增强人物关系", "summary": "增强人物关系"}, + ) + + response = client.get( + f"/v1/author/works/{work['work_id']}/export", + headers=headers, + params={"format": "nosbook", "route": "active"}, + ) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/vnd.narrativeos.nosbook+json") + payload = response.json() + assert payload["schema_version"] == "nosbook/v1" + assert payload["export_route"]["route_name"].startswith("路线 A") + assert payload["chapters"][0]["choice_impacts"][0]["risk_level"] == "高" + assert any(item["route_name"] == "主线" for item in payload["branch_map"]) + assert any(item["selected_choice"] == "保护证人" for item in payload["choice_history"]) + assert payload["quality_summary"] == { + "重复感": "良好", + "场景细节": "充足", + "节奏": "稳定", + "结尾风险": "正常", + } + assert branch["is_active_line"] is True + + +def test_agent_studio_shell_assets_are_registered_and_parseable(tmp_path: Path): + app = create_app(repository=SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "agent_studio_shell.db"))) + client = TestClient(app) + + shell = client.get("/app") + assert shell.status_code == 200 + assert 'id="agent-studio-shell"' in shell.text + assert "/assets/agent_studio_dom.js" in shell.text + assert "/assets/agent_studio.js" in shell.text + assert shell.text.index("/assets/author_workspace.js") < shell.text.index("/assets/agent_studio.js") + assert "重复感" in shell.text + agent_shell_slice = shell.text[shell.text.index('id="agent-studio-shell"') : shell.text.index('id="customer-shell"')] + assert "Q03" not in agent_shell_slice + + runtime = client.get("/assets/agent_studio.js") + assert runtime.status_code == 200 + assert "AgentStudioRuntime" in runtime.text + assert "choice_impacts" in runtime.text + assert "lastNosbookExport" in runtime.text + assert "NOSBOOK_CONTENT_TYPE" in runtime.text + assert "application/vnd.narrativeos.nosbook+json" in runtime.text + assert "generationStatus" in runtime.text + assert "第一章生成中" in runtime.text + assert "正在建立作品设定、人物冲突和章节正文,可能需要一两分钟。" in runtime.text + assert "续写中" in runtime.text + assert "正在沿导演意图推进下一章,完成后会自动跳到新章节。" in runtime.text + assert "新路线创建中" in runtime.text + assert "正在从当前章节保存分支。" in runtime.text + assert "Q03" not in runtime.text + assert "Q04" not in runtime.text + assert "Q05" not in runtime.text + assert "Q09" not in runtime.text + + node = shutil.which("node") + if node: + subprocess.run( + [node, "-e", f"new Function(require('fs').readFileSync({(ROOT / 'src/narrativeos/web/agent_studio.js').as_posix()!r}, 'utf8'));"], + check=True, + cwd=ROOT, + ) + + +def test_agent_studio_layout_css_keeps_reader_primary(): + styles = (ROOT / "src/narrativeos/web/styles.css").read_text() + shell_status = (ROOT / "src/narrativeos/web/shell_status_runtime.js").read_text() + + assert 'data-author-workspace="studio"' in styles + assert 'grid-template-areas: "rail reader director"' not in styles + assert '"reader director"' in styles + assert '"rail director"' in styles + assert 'minmax(320px, 360px)' in styles + assert "min-height: 560px;" in styles + assert "min-height: clamp(860px, calc(100vh - 120px), 1040px);" in styles + assert "grid-template-rows: auto minmax(560px, min(64vh, 680px)) auto auto;" in styles + assert "max-height: min(720px, 72vh);" in styles + assert "position: sticky;" in styles + assert "top: 16px;" in styles + assert ".toolbar-group--utility .status-grid--compact" in styles + assert ".session-summary .toolbar-label" in styles + assert "overflow: visible;" in styles + assert "max-height: min(360px, 42vh);" in styles + assert '"director"' in styles + assert '"reader"' in styles + assert '"rail"' in styles + assert '[data-author-workspace="studio"] #shell-status-banner' in styles + assert "shellDom.appShell.dataset.authorWorkspace" in shell_status + + +def test_agent_studio_local_launcher_opens_studio_frontend(): + launcher = ROOT / "scripts" / "run_agent_studio_local.sh" + assert launcher.exists() + assert launcher.stat().st_mode & 0o111 + + text = launcher.read_text(encoding="utf-8") + assert "AGENT_STUDIO_URL" in text + assert "product=author&workspace=studio&debug=1" in text + assert "AGENT_STUDIO_OPEN_BROWSER" in text + assert "scripts/run_backend_local.sh" in text + assert "/health" in text + assert "agent_studio_frontend:" in text + assert "open \"${AGENT_STUDIO_URL}\"" in text + assert "xdg-open \"${AGENT_STUDIO_URL}\"" in text + assert "python3 -m webbrowser \"${AGENT_STUDIO_URL}\"" in text diff --git a/tests/test_author_workflow.py b/tests/test_author_workflow.py index f78ee23..f50b4e8 100644 --- a/tests/test_author_workflow.py +++ b/tests/test_author_workflow.py @@ -25,6 +25,21 @@ def _grant_author_access(repository: SQLAlchemyRepository, *, account_id: str = return billing +def _auth_headers(client: TestClient, *, actor_id: str, actor_role: str = "author") -> dict[str, str]: + client.post( + "/v1/auth/register", + json={ + "actor_id": actor_id, + "actor_role": actor_role, + "password": "secret123", + "account_id": actor_id, + }, + ) + login = client.post("/v1/auth/login", json={"actor_id": actor_id, "password": "secret123"}) + assert login.status_code == 200 + return {"Authorization": f"Bearer {login.json()['token']['access_token']}"} + + def _brief_payload(account_id: str, *, preset: str = "synthetic") -> dict: return { "genre_preset": preset, @@ -88,6 +103,123 @@ def test_author_workflow_summary_recommends_simulate_for_auto_validated_draft(tm assert summary["validation_summary"]["ok"] is True +def test_author_draft_backfills_dialogue_assets_for_new_characters(tmp_path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_workflow_asset_coverage.db")) + billing = _grant_author_access(repository, account_id="acct_author") + authoring = AuthoringService(repository, billing_service=billing) + + draft = authoring.create_draft_from_brief(_brief_payload("acct_author")) + detail = authoring.get_draft(draft["world_version_id"]) + worldpack = detail["worldpack"] + new_character = dict(worldpack["characters"][0]) + new_character["character_id"] = "supporting_new" + new_character["display_name"] = "新角色" + new_character["role"] = "supporting" + worldpack["characters"].append(new_character) + worldpack["character_memory_profiles"].pop("supporting_new", None) + + updated = authoring.update_draft( + draft["world_version_id"], + worldpack, + change_context={"source": "character_editor", "label": "补角色"}, + ) + + assert updated["validation_report"]["ok"] is True + assert "supporting_new" in updated["worldpack"]["character_memory_profiles"] + + +def test_author_brief_auto_enriches_100_band_structure(tmp_path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_workflow_band100.db")) + billing = _grant_author_access(repository, account_id="acct_author") + authoring = AuthoringService(repository, billing_service=billing) + + draft = authoring.create_draft_from_brief( + { + **_brief_payload("acct_author", preset="xianxia"), + "target_total_chapters": 100, + "target_total_volumes": 10, + "target_word_count": 220000, + } + ) + detail = authoring.get_draft(draft["world_version_id"]) + + assert detail["entry_mode"] == "quick_brief" + assert detail["requested_target_band"] == "100" + assert detail["supported_target_band"] == "100" + assert detail["claim_safe_band"] == "100" + assert detail["requires_structured_longform"] is False + assert detail["longform_readiness"]["status"] == "ready" + assert detail["longform_structure_counts"]["character_count"] >= 8 + assert detail["longform_structure_counts"]["scene_blueprint_count"] >= 8 + assert detail["longform_structure_counts"]["location_count"] >= 6 + assert detail["longform_structure_counts"]["scene_family_count"] >= 6 + assert detail["longform_structure_counts"]["distinct_role_pair_count"] >= 6 + assert detail["quick_brief_runway_summary"]["status"] == "ready" + assert detail["quick_brief_runway_summary"]["scene_family_count"] >= 6 + assert detail["quick_brief_runway_summary"]["distinct_role_pair_count"] >= 6 + assert detail["worldpack"]["series_plan"]["series_promises"] + assert detail["worldpack"]["volume_plans"][0]["volume_promises"] + assert detail["worldpack"]["arc_plans"][0]["arc_promises"] + assert all(task["promise_targets"] for arc in detail["worldpack"]["arc_plans"] for task in arc["chapter_tasks"] if not task.get("bridge_only")) + + +def test_author_brief_over_100_blocks_until_structured_longform(tmp_path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_workflow_band1000_blocked.db")) + billing = _grant_author_access(repository, account_id="acct_author") + authoring = AuthoringService(repository, billing_service=billing) + + draft = authoring.create_draft_from_brief( + { + **_brief_payload("acct_author", preset="xianxia"), + "target_total_chapters": 1000, + "target_total_volumes": 18, + "target_word_count": 2000000, + } + ) + detail = authoring.get_draft(draft["world_version_id"]) + summary = authoring.workflow_summary(account_id="acct_author", world_version_id=draft["world_version_id"]) + + assert detail["entry_mode"] == "quick_brief" + assert detail["requested_target_band"] == "1000" + assert detail["requires_structured_longform"] is True + assert detail["longform_readiness"]["status"] == "blocked" + assert any(item["key"] == "structured_longform_required" for item in detail["longform_readiness"]["blockers"]) + assert summary["recommended_action"] == "bootstrap_structured_longform" + assert summary["longform_readiness"]["status"] == "blocked" + + +def test_structured_longform_bootstrap_can_raise_safe_band_to_1000(tmp_path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_workflow_band1000_ready.db")) + billing = _grant_author_access(repository, account_id="acct_author") + authoring = AuthoringService(repository, billing_service=billing) + + draft = authoring.create_draft_from_brief( + { + **_brief_payload("acct_author", preset="xianxia"), + "target_total_chapters": 1000, + "target_total_volumes": 18, + "target_word_count": 2000000, + } + ) + structured = authoring.bootstrap_longform_workbench( + draft["world_version_id"], + mode="structured_longform", + target_band="1000", + ) + + assert structured["entry_mode"] == "structured_longform" + assert structured["requested_target_band"] == "1000" + assert structured["supported_target_band"] == "1000" + assert structured["claim_safe_band"] == "1000" + assert structured["requires_structured_longform"] is False + assert structured["longform_readiness"]["status"] == "ready" + assert structured["longform_structure_counts"]["character_count"] >= 24 + assert structured["longform_structure_counts"]["scene_blueprint_count"] >= 24 + assert structured["longform_structure_counts"]["location_count"] >= 16 + assert structured["longform_structure_counts"]["scene_family_count"] >= 12 + assert structured["longform_structure_counts"]["distinct_role_pair_count"] >= 12 + + def test_author_workflow_summary_recommends_validate_when_validation_missing(tmp_path): repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_workflow_validate.db")) billing = _grant_author_access(repository, account_id="acct_author") @@ -150,18 +282,30 @@ def test_author_workflow_api_returns_expected_fields_and_stage_transitions(tmp_p _grant_author_access(repository, account_id="acct_author") app = create_app(repository=repository) client = TestClient(app) + author_headers = _auth_headers(client, actor_id="acct_author") + reviewer_headers = _auth_headers(client, actor_id="workflow_reviewer", actor_role="reviewer") - draft = client.post("/v1/author/drafts/from-brief", json={"brief": _brief_payload("acct_author")}) + draft = client.post("/v1/author/drafts/from-brief", headers=author_headers, json={"brief": _brief_payload("acct_author")}) assert draft.status_code == 200 draft_id = draft.json()["world_version_id"] - validated = client.get(f"/v1/author/workflow?account_id=acct_author&world_version_id={draft_id}") + validated = client.get(f"/v1/author/workflow?account_id=acct_author&world_version_id={draft_id}", headers=author_headers) assert validated.status_code == 200 payload = validated.json() for key in ( "account_id", "world_version_id", "world_id", + "entry_mode", + "requested_target_chapters", + "requested_target_band", + "supported_target_band", + "claim_safe_band", + "requires_structured_longform", + "longform_readiness", + "longform_structure_counts", + "quick_brief_runway_summary", + "promise_runway_summary", "stage", "recommended_action", "blockers", @@ -176,12 +320,96 @@ def test_author_workflow_api_returns_expected_fields_and_stage_transitions(tmp_p assert payload["recommended_action"] == "simulate" _mark_simulation_fresh(repository, draft_id, decision="pass") - ready = client.get(f"/v1/author/workflow?account_id=acct_author&world_version_id={draft_id}") + ready = client.get(f"/v1/author/workflow?account_id=acct_author&world_version_id={draft_id}", headers=author_headers) assert ready.status_code == 200 assert ready.json()["recommended_action"] == "submit" - submitted = client.post(f"/v1/author/drafts/{draft_id}/submit?account_id=acct_author") + submitted = client.post(f"/v1/author/drafts/{draft_id}/submit?account_id=acct_author", headers=author_headers) assert submitted.status_code == 200 - waiting = client.get(f"/v1/author/workflow?account_id=acct_author&world_version_id={draft_id}") + author_audit = client.get("/v1/ops/audit", headers=author_headers, params={"account_id": "acct_author", "limit": 20}) + assert author_audit.status_code == 403 + audit = client.get("/v1/ops/audit", headers=reviewer_headers, params={"account_id": "acct_author", "limit": 20}) + assert audit.status_code == 200 + assert any(item["action_type"] == "author_draft_submitted" for item in audit.json()["audit_logs"]) + waiting = client.get(f"/v1/author/workflow?account_id=acct_author&world_version_id={draft_id}", headers=author_headers) assert waiting.status_code == 200 assert waiting.json()["recommended_action"] == "wait_for_review" + + +def test_author_simulate_api_accepts_serverless_safe_scope(tmp_path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_workflow_simulate_scope.db")) + _grant_author_access(repository, account_id="acct_author") + app = create_app(repository=repository) + client = TestClient(app) + author_headers = _auth_headers(client, actor_id="acct_author") + + draft = client.post("/v1/author/drafts/from-brief", headers=author_headers, json={"brief": _brief_payload("acct_author")}) + assert draft.status_code == 200 + draft_id = draft.json()["world_version_id"] + + captured = {} + + def fake_run_simulation(world_version_id, **kwargs): + captured.update({"world_version_id": world_version_id, **kwargs}) + return { + "ok": True, + "latest_decision": "pass", + "completed_chapters": kwargs.get("max_chapters"), + "stop_reason": "chapter_budget_reached", + "evaluation_summary": { + "pass_rate": 1.0, + "rewrite_rate": 0.0, + "block_rate": 0.0, + "next_actions": [], + }, + } + + app.state.authoring_service.run_simulation_for_world_version = fake_run_simulation + + simulated = client.post( + f"/v1/author/drafts/{draft_id}/simulate", + headers=author_headers, + json={"include_cross_pack": False, "max_chapters": 2}, + ) + + assert simulated.status_code == 200 + assert captured["world_version_id"] == draft_id + assert captured["include_cross_pack"] is False + assert captured["max_chapters"] == 2 + assert simulated.json()["completed_chapters"] == 2 + + +def test_author_longform_bootstrap_api_supports_structured_target_band(tmp_path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_workflow_bootstrap_api.db")) + _grant_author_access(repository, account_id="acct_author") + app = create_app(repository=repository) + client = TestClient(app) + author_headers = _auth_headers(client, actor_id="acct_author") + + draft = client.post( + "/v1/author/drafts/from-brief", + headers=author_headers, + json={ + "brief": { + **_brief_payload("acct_author", preset="xianxia"), + "target_total_chapters": 1000, + "target_total_volumes": 18, + "target_word_count": 2000000, + } + }, + ) + assert draft.status_code == 200 + draft_id = draft.json()["world_version_id"] + assert draft.json()["requires_structured_longform"] is True + + bootstrapped = client.post( + f"/v1/author/drafts/{draft_id}/longform-bootstrap", + headers=author_headers, + json={"account_id": "acct_author", "mode": "structured_longform", "target_band": "1000"}, + ) + assert bootstrapped.status_code == 200 + payload = bootstrapped.json() + assert payload["entry_mode"] == "structured_longform" + assert payload["supported_target_band"] == "1000" + assert payload["claim_safe_band"] == "1000" + assert payload["longform_readiness"]["status"] == "ready" diff --git a/tests/test_author_works.py b/tests/test_author_works.py new file mode 100644 index 0000000..f4ca5b2 --- /dev/null +++ b/tests/test_author_works.py @@ -0,0 +1,2048 @@ +import copy +import json +from pathlib import Path + +from fastapi.testclient import TestClient + +from src.narrativeos.api import create_app +from src.narrativeos.core.linter import story_text_unit_count +from src.narrativeos.eval.service import evaluate_persisted_chapter +from src.narrativeos.models import NarrativeState +from src.narrativeos.repository import SQLAlchemyRepository +from src.narrativeos.services.authoring import AuthoringService +from src.narrativeos.worldpacks.registry import FileSystemWorldRegistry + + +def _auth_headers(client: TestClient, *, actor_id: str, actor_role: str, password: str) -> dict[str, str]: + client.post( + "/v1/auth/register", + json={ + "actor_id": actor_id, + "actor_role": actor_role, + "password": password, + "account_id": actor_id, + }, + ) + login = client.post("/v1/auth/login", json={"actor_id": actor_id, "password": password}) + assert login.status_code == 200 + return {"Authorization": f"Bearer {login.json()['token']['access_token']}"} + + +def _simple_state(chapter_index: int = 1) -> NarrativeState: + return NarrativeState.from_dict( + { + "state_id": f"test_state_{chapter_index}", + "world_id": "test_world", + "turn_index": chapter_index, + "story_phase": "setup", + "chapter_index": chapter_index, + "min_end_turn": 8, + "fate_pressure": 0.0, + "karmic_weather": {}, + "unresolved_debts": [], + "world_facts": [], + "timeline": [], + "characters": {}, + "relationship_graph": [], + "open_promises": [], + "tension": 0.3, + "themes": {}, + "player_intent": {}, + "recent_scene_functions": [], + "visited_event_ids": [], + "route_fingerprint": [], + "rating_ceiling": "PG13", + } + ) + + +def _seed_tide_archive_strategy_bundle_draft( + authoring: AuthoringService, + repository: SQLAlchemyRepository, + *, + author_id: str = "", +) -> dict[str, object]: + pack = copy.deepcopy(authoring.registry.get_published_world("tide_archive_memory_debt")["worldpack"]) + if author_id: + pack["manifest"]["author_id"] = author_id + pack["metadata"] = {**dict(pack.get("metadata") or {}), "author_brief": {"author_id": author_id}} + draft = authoring.save_draft(pack) + detail = authoring.get_draft(draft["world_version_id"]) + worldpack = detail["worldpack"] + early_scene = worldpack["scene_blueprints"][0] + mid_scene = next(item for item in worldpack["scene_blueprints"] if item["scene_id"] == "submerged_return") + late_arc = worldpack["arc_plans"][-1] + late_task = late_arc["chapter_tasks"][0] + chapter_heatmap = [ + { + "chapter_index": 2, + "chapter_title": "第2章", + "decision": "rewrite", + "severity": "watch", + "overall_score": 0.79, + "issue_count": 2, + "issue_codes": ["Q03", "Q04"], + "scene_function": early_scene["scene_function"], + "scene_id": early_scene["scene_id"], + "chapter_task_id": worldpack["arc_plans"][0]["chapter_tasks"][0]["chapter_task_id"], + "arc_id": worldpack["arc_plans"][0]["arc_id"], + "volume_id": worldpack["arc_plans"][0]["volume_id"], + "related_character_ids": list(early_scene["required_roles"]), + "related_characters": list(early_scene["required_roles"]), + }, + { + "chapter_index": 35, + "chapter_title": "第35章", + "decision": "rewrite", + "severity": "watch", + "overall_score": 0.76, + "issue_count": 2, + "issue_codes": ["Q03", "Q04"], + "scene_function": mid_scene["scene_function"], + "scene_id": mid_scene["scene_id"], + "chapter_task_id": worldpack["arc_plans"][5]["chapter_tasks"][0]["chapter_task_id"], + "arc_id": worldpack["arc_plans"][5]["arc_id"], + "volume_id": worldpack["arc_plans"][5]["volume_id"], + "related_character_ids": list(mid_scene["required_roles"]), + "related_characters": list(mid_scene["required_roles"]), + }, + { + "chapter_index": 85, + "chapter_title": "第85章", + "decision": "rewrite", + "severity": "watch", + "overall_score": 0.74, + "issue_count": 1, + "issue_codes": ["Q09"], + "scene_function": "truth_trial", + "scene_id": "", + "chapter_task_id": late_task["chapter_task_id"], + "arc_id": late_arc["arc_id"], + "volume_id": late_arc["volume_id"], + "related_character_ids": ["wen_xi", "gu_chenzhou"], + "related_characters": ["闻汐", "顾沉舟"], + }, + ] + baseline_report = { + "chapter_budget": 100, + "longform_plan_snapshot": { + "series_plan": worldpack["series_plan"], + "volume_plans": worldpack["volume_plans"], + "arc_plans": worldpack["arc_plans"], + "chapter_budget_policy": worldpack["chapter_budget_policy"], + }, + "evaluation_summary": { + "pass_rate": 0.52, + "rewrite_rate": 0.48, + "block_rate": 0.0, + "dialogue_ratio": 0.395, + "avg_repetition_score": 0.289, + "avg_exposition_ratio": 0.694, + }, + "longform_summary": { + "mid_arc_pass_rate": 0.758, + "late_arc_pass_rate": 0.441, + "q09_incidence_rate": 0.18, + }, + "content_quality_contract_window_metrics": { + "enabled": True, + "early_window_q03_q04_share": 0.8, + "mid_window_repeat_breach_rate": 0.7, + "mid_window_exposition_breach_rate": 0.66, + "late_window_q09_breach_rate": 0.4, + "thresholds": { + "early_window_q03_q04_share_max": 0.45, + "mid_window_repeat_breach_rate_max": 0.30, + "mid_window_exposition_breach_rate_max": 0.30, + "late_window_q09_breach_rate_max": 0.08, + }, + "contract_failed_chapters": [ + {"chapter_id": "chapter_2", "chapter_index": 2, "failed_checks": ["repetition_score_cap", "exposition_ratio_cap"], "decision": "rewrite"}, + {"chapter_id": "chapter_35", "chapter_index": 35, "failed_checks": ["repetition_score_cap", "exposition_ratio_cap"], "decision": "rewrite"}, + {"chapter_id": "chapter_85", "chapter_index": 85, "failed_checks": ["q09_pre_end", "continuation_pressure_floor"], "decision": "rewrite"}, + ], + }, + "creative_cockpit": { + "chapter_heatmap": { + "chapters": chapter_heatmap, + "issue_priority_groups": authoring._build_issue_priority_groups(chapter_heatmap), + } + }, + "chapter_evaluations": [], + "latest_repair_loop_outcome": {}, + "repair_loop_history": [], + "latest_strategy_bundle_execution": {}, + "strategy_bundle_execution_history": [], + } + version = repository.get_world_version(draft["world_version_id"]) + version.simulation_report_json = copy.deepcopy(baseline_report) + repository.save_world_version(version, publish=False) + return { + "draft": draft, + "worldpack": worldpack, + "baseline_report": baseline_report, + "early_scene": early_scene, + "mid_scene": mid_scene, + "late_arc": late_arc, + "late_task": late_task, + } + + +def test_shared_chapter_quality_gate_enforces_budget_and_pass_decision(): + good_body = "\n\n".join( + [ + "旧站台的铁锈味被潮风一点点顶起来,沈砚把录音带从口袋里摸出来时,塑料外壳碰到栏杆,发出很轻的一响。林潮没有马上接,只把雨伞靠在售票窗边,低声问:“你到底还打不打算把那晚缺掉的话说完?”", + "他沿着站台边缘向前走了两步,鞋底压过碎石,像把迟疑一粒粒踩碎。远处列车灯掠过黑水,映得她袖口那道旧折痕更明显。沈砚抬眼看她:“我不是怕真相,我是怕你听完以后连回头都不会。”", + "林潮把湿掉的票根摊平在掌心,纸边卷起,像一条一直没能压好的时间线。她没有替自己辩解,只把票根递到他手边:“三年前你转身的时候,我就知道以后每一次重逢都得从这里重新开始。”", + "站台顶棚被风吹得轻晃,灯影一格一格落下来,刚好把两个人隔在明暗交界处。沈砚没再躲,伸手接过票根时指腹被纸边割得发麻,却还是把那句压了太久的话送了出去:“我那时不是不信你,我是不敢让自己信。”", + "话落下去以后,最先响起来的是远处铁轨深处的一阵空鸣。林潮慢慢把手收回衣袋,肩线却没有再绷得那么硬。她看着他:“那你现在最好想清楚,这句认下来以后,不只是你一个人要付代价。”", + "沈砚靠在褪色的站牌上,没有像从前那样替任何人做决定。他把录音带放到两人中间的长椅上,像把证据和选择一起摊开:“这次我不替你选,也不替自己留后门。你要走,我认;你要继续查,我陪。”", + "风从废弃检票口灌进来,带得玻璃窗轻轻发颤。林潮低头看着那卷录音带,忽然笑了一下,笑意却很淡:“你总算学会先问一句我愿不愿意。”她弯腰把磁带收进包里,动作稳得像把下一步也一并定了下来。", + "两个人并肩离开站台时,脚边积水映着路灯,一路晃出细碎的光。谁都没有说和好,也没有给未来下定义,可那卷终于被共同带走的录音带,已经把下一章要面对的真相和关系一起推到了眼前。" + ] + ) + good = evaluate_persisted_chapter( + chapter_id="chapter_good", + world_version_id="world_v1", + session_id="session_1", + body=good_body, + paragraphs=good_body.split("\n\n"), + dialogue_count=4, + action_count=6, + detail_count=6, + character_fidelity_score=0.9, + state_after=_simple_state(), + ending_ready=False, + choices=["继续追问", "先忍住不说"], + paywall_required=False, + target_words=700, + min_target_words=600, + ) + assert good["quality_gate"]["ok"] is True + + short_body = "他看了她一眼。\\n\\n“走吧。”" + short = evaluate_persisted_chapter( + chapter_id="chapter_short", + world_version_id="world_v1", + session_id="session_1", + body=short_body, + paragraphs=short_body.split("\n\n"), + dialogue_count=1, + action_count=0, + detail_count=0, + character_fidelity_score=0.9, + state_after=_simple_state(), + ending_ready=False, + choices=["继续", "离开"], + paywall_required=False, + target_words=2000, + min_target_words=1800, + ) + assert short["quality_gate"]["ok"] is False + assert "text_unit_floor_not_met" in short["quality_gate"]["failed_checks"] + assert short["quality_gate"]["decision"] in {"rewrite", "block"} + + +def test_shared_chapter_quality_gate_blocks_disallowed_latin_tokens_in_visible_story_text(): + good_body = "\n\n".join( + [ + "旧站台的铁锈味被潮风一点点顶起来,沈砚把录音带从口袋里摸出来时,塑料外壳碰到栏杆,发出很轻的一响。林潮没有马上接,只把雨伞靠在售票窗边,低声问:“你到底还打不打算把那晚缺掉的话说完?”", + "他沿着站台边缘向前走了两步,鞋底压过碎石,像把迟疑一粒粒踩碎。远处列车灯掠过黑水,映得她袖口那道旧折痕更明显。沈砚抬眼看她:“我不是怕真相,我是怕你听完以后连回头都不会。”", + "林潮把湿掉的票根摊平在掌心,纸边卷起,像一条一直没能压好的时间线。她没有替自己辩解,只把票根递到他手边:“三年前你转身的时候,我就知道以后每一次重逢都得从这里重新开始。”", + "站台顶棚被风吹得轻晃,灯影一格一格落下来,刚好把两个人隔在明暗交界处。沈砚没再躲,伸手接过票根时指腹被纸边割得发麻,却还是把那句压了太久的话送了出去:“我那时不是不信你,我是不敢让自己信。”", + "话落下去以后,最先响起来的是远处铁轨深处的一阵空鸣。林潮慢慢把手收回衣袋,肩线却没有再绷得那么硬。她看着他:“那你现在最好想清楚,这句认下来以后,不只是你一个人要付代价。”", + "沈砚靠在褪色的站牌上,没有像从前那样替任何人做决定。他把录音带放到两人中间的长椅上,像把证据和选择一起摊开:“这次我不替你选,也不替自己留后门。你要走,我认;你要继续查,我陪。”", + "风从废弃检票口灌进来,带得玻璃窗轻轻发颤。林潮低头看着那卷录音带,忽然笑了一下,笑意却很淡:“你总算学会先问一句我愿不愿意。”她弯腰把磁带收进包里,动作稳得像把下一步也一并定了下来。", + "两个人并肩离开站台时,脚边积水映着路灯,一路晃出细碎的光。谁都没有说和好,也没有给未来下定义,可那卷终于被共同带走的录音带,已经把下一章要面对的真相和关系一起推到了眼前。", + ] + ) + + allowed = evaluate_persisted_chapter( + chapter_id="chapter_allowed_uppercase", + world_version_id="world_v1", + session_id="session_1", + body=good_body, + paragraphs=good_body.split("\n\n"), + dialogue_count=4, + action_count=6, + detail_count=6, + character_fidelity_score=0.9, + state_after=_simple_state(), + ending_ready=False, + chapter_title="第 1 章 · AI 回响", + recap="前情提要:AI 留下的痕迹还没有散。", + relationship_hints=["甲对乙多了一点信任。", "URL 只是缩写,不算正文英文。"], + choices=["继续查看 API 留下的痕迹", "先不追问"], + paywall_required=False, + target_words=700, + min_target_words=600, + ) + assert allowed["quality_gate"]["ok"] is True + assert allowed["quality_gate"]["disallowed_latin_token_hits"] == [] + + blocked_body = evaluate_persisted_chapter( + chapter_id="chapter_bad_body_token", + world_version_id="world_v1", + session_id="session_1", + body=good_body + "\n\n她把 temptation 压回喉间,却还是没能把那句话彻底收住。", + paragraphs=(good_body + "\n\n她把 temptation 压回喉间,却还是没能把那句话彻底收住。").split("\n\n"), + dialogue_count=4, + action_count=6, + detail_count=6, + character_fidelity_score=0.9, + state_after=_simple_state(), + ending_ready=False, + chapter_title="第 1 章 · 表面平静下的暗潮", + recap="前情提要:他们把 temptation 压回了沉默里。", + relationship_hints=["甲对乙多了一点信任。"], + choices=["继续追问", "先忍住不说"], + paywall_required=False, + target_words=700, + min_target_words=600, + ) + assert blocked_body["quality_gate"]["ok"] is False + assert "disallowed_latin_token_detected" in blocked_body["quality_gate"]["failed_checks"] + assert "body" in blocked_body["quality_gate"]["latin_token_fields"] + assert "temptation" in blocked_body["quality_gate"]["latin_token_tokens"] + + blocked_title = evaluate_persisted_chapter( + chapter_id="chapter_bad_title_token", + world_version_id="world_v1", + session_id="session_1", + body=good_body, + paragraphs=good_body.split("\n\n"), + dialogue_count=4, + action_count=6, + detail_count=6, + character_fidelity_score=0.9, + state_after=_simple_state(), + ending_ready=False, + chapter_title="第 1 章 · temptation 回潮", + recap="前情提要:风声还压在窗边。", + relationship_hints=["甲对乙多了一点信任。"], + choices=["继续追问", "先忍住不说"], + paywall_required=False, + target_words=700, + min_target_words=600, + ) + assert blocked_title["quality_gate"]["ok"] is False + assert blocked_title["quality_gate"]["latin_token_fields"] == ["chapter_title"] + + blocked_choice = evaluate_persisted_chapter( + chapter_id="chapter_bad_choice_token", + world_version_id="world_v1", + session_id="session_1", + body=good_body, + paragraphs=good_body.split("\n\n"), + dialogue_count=4, + action_count=6, + detail_count=6, + character_fidelity_score=0.9, + state_after=_simple_state(), + ending_ready=False, + chapter_title="第 1 章 · 表面平静下的暗潮", + recap="前情提要:风声还压在窗边。", + relationship_hints=["甲对乙多了一点信任。"], + choices=["先压下 temptation", "继续追问"], + paywall_required=False, + target_words=700, + min_target_words=600, + ) + assert blocked_choice["quality_gate"]["ok"] is False + assert blocked_choice["quality_gate"]["latin_token_fields"] == ["choice_1"] + + blocked_recap = evaluate_persisted_chapter( + chapter_id="chapter_bad_recap_token", + world_version_id="world_v1", + session_id="session_1", + body=good_body, + paragraphs=good_body.split("\n\n"), + dialogue_count=4, + action_count=6, + detail_count=6, + character_fidelity_score=0.9, + state_after=_simple_state(), + ending_ready=False, + chapter_title="第 1 章 · 表面平静下的暗潮", + recap="前情提要:她把 temptation 压回了沉默里。", + relationship_hints=["甲对乙多了一点信任。"], + choices=["继续追问", "先忍住不说"], + paywall_required=False, + target_words=700, + min_target_words=600, + ) + assert blocked_recap["quality_gate"]["ok"] is False + assert blocked_recap["quality_gate"]["latin_token_fields"] == ["recap"] + + blocked_hint = evaluate_persisted_chapter( + chapter_id="chapter_bad_relationship_hint_token", + world_version_id="world_v1", + session_id="session_1", + body=good_body, + paragraphs=good_body.split("\n\n"), + dialogue_count=4, + action_count=6, + detail_count=6, + character_fidelity_score=0.9, + state_after=_simple_state(), + ending_ready=False, + chapter_title="第 1 章 · 表面平静下的暗潮", + recap="前情提要:风声还压在窗边。", + relationship_hints=["甲对乙多了一点 temptation。"], + choices=["继续追问", "先忍住不说"], + paywall_required=False, + target_words=700, + min_target_words=600, + ) + assert blocked_hint["quality_gate"]["ok"] is False + assert blocked_hint["quality_gate"]["latin_token_fields"] == ["relationship_hint_1"] + + +def test_content_quality_contract_gate_blocks_rolling_q03_breach_in_100_band(): + repeated_paragraph = ( + "灯影落在窗纸上,风从檐下掠过去,她没有立刻说话,只把那卷录音带重新压回掌心里," + "灯影落在窗纸上,风从檐下掠过去,她没有立刻说话,只把那卷录音带重新压回掌心里。" + ) + body = "\n\n".join([repeated_paragraph for _ in range(9)]) + result = evaluate_persisted_chapter( + chapter_id="chapter_contract_q03", + world_version_id="world_v1", + session_id="session_1", + body=body, + paragraphs=body.split("\n\n"), + dialogue_count=0, + action_count=1, + detail_count=1, + character_fidelity_score=0.8, + state_after=_simple_state(35), + ending_ready=False, + choices=["继续追问", "先忍住不说"], + paywall_required=False, + target_words=2000, + min_target_words=1800, + chapter_index=35, + target_chapters=100, + rolling_quality_window=[ + { + "chapter_index": 34, + "decision": "rewrite", + "issue_codes": ["Q03"], + "repetition_score": 0.24, + "exposition_ratio": 0.3, + "concrete_detail_density": 0.05, + "dialogue_plus_action_ratio": 0.45, + "hook_quality": 0.9, + "scene_function": "truth_trial", + "chapter_task_id": "task_prev", + } + ], + enforcement_scope="author_work_generation", + ) + gate = result["quality_gate"] + assert gate["ok"] is False + assert "repetition_score_cap" in gate["failed_checks"] + assert "rolling_window_repeat_breach" in gate["failed_checks"] + assert gate["enforced_decision"] == "block" + assert gate["primary_asset_target"]["asset_type"] == "scene_blueprint" + assert gate["window_breach_kind"] in {"repetition_score_cap", "rolling_window_repeat_breach"} + assert gate["enforcement_scope"] == "author_work_generation" + + +def test_content_quality_contract_gate_blocks_q09_before_late_window_ending(): + good_body = "\n\n".join( + [ + "旧站台的风把灯影吹得一格格晃开,沈砚没有立刻接话,只把录音带更稳地压在掌心里。", + "她站在检票口边,没有替他退路,只问了一句更难回答的话,让场面一下子更紧。", + "远处列车灯掠过积水,细小回声从铁轨深处顶回来,像把后面的代价也提前带到了眼前。", + "沈砚知道这一次再装作没发生,下一次会被追上的不只是旧案,还有他们两个人没认下的话。", + "他抬眼看她,终于把那句最难听的部分送出来,可真正重的,是这句说完以后还要不要继续往前。", + "她没有说原谅,只把那卷录音带推回他手边,让选择和真相都一起停在两个人中间。", + "风声、铁锈和脚步声没有停,反而把下一步该承担的那层压力照得更清。", + "这一章没有给出结局,只把更大的追问推到了下一次开口之前。" + ] + ) + result = evaluate_persisted_chapter( + chapter_id="chapter_contract_q09", + world_version_id="world_v1", + session_id="session_1", + body=good_body, + paragraphs=good_body.split("\n\n"), + dialogue_count=3, + action_count=6, + detail_count=6, + character_fidelity_score=0.85, + state_after=_simple_state(40), + ending_ready=True, + choices=["继续追问", "先保住她"], + paywall_required=False, + target_words=2000, + min_target_words=1800, + chapter_index=40, + target_chapters=100, + enforcement_scope="reader_session_generation", + ) + gate = result["quality_gate"] + assert gate["ok"] is False + assert "premature_terminal_forbidden" in gate["failed_checks"] + assert gate["primary_issue_group"] == "Q09" + assert gate["enforced_decision"] == "block" + assert gate["primary_asset_target"]["asset_type"] == "chapter_task" + + +def test_draft_detail_builds_content_quality_repair_workbench_for_tide_archive_windows(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "tide_archive_repair_workbench.db")) + registry = FileSystemWorldRegistry() + authoring = AuthoringService(repository, registry=registry) + + pack = registry.get_published_world("tide_archive_memory_debt")["worldpack"] + draft = authoring.save_draft(pack) + detail = authoring.get_draft(draft["world_version_id"]) + worldpack = detail["worldpack"] + early_scene = worldpack["scene_blueprints"][0] + mid_scene = next(item for item in worldpack["scene_blueprints"] if item["scene_id"] == "submerged_return") + late_arc = worldpack["arc_plans"][-1] + late_task = late_arc["chapter_tasks"][0] + late_volume_id = late_arc["volume_id"] + + chapter_heatmap = [ + { + "chapter_index": 2, + "chapter_title": "第2章", + "decision": "rewrite", + "severity": "watch", + "overall_score": 0.79, + "issue_count": 2, + "issue_codes": ["Q03", "Q04"], + "scene_function": early_scene["scene_function"], + "scene_id": early_scene["scene_id"], + "chapter_task_id": worldpack["arc_plans"][0]["chapter_tasks"][0]["chapter_task_id"], + "arc_id": worldpack["arc_plans"][0]["arc_id"], + "volume_id": worldpack["arc_plans"][0]["volume_id"], + "related_character_ids": list(early_scene["required_roles"]), + "related_characters": list(early_scene["required_roles"]), + }, + { + "chapter_index": 35, + "chapter_title": "第35章", + "decision": "rewrite", + "severity": "watch", + "overall_score": 0.76, + "issue_count": 2, + "issue_codes": ["Q03", "Q04"], + "scene_function": mid_scene["scene_function"], + "scene_id": mid_scene["scene_id"], + "chapter_task_id": worldpack["arc_plans"][5]["chapter_tasks"][0]["chapter_task_id"], + "arc_id": worldpack["arc_plans"][5]["arc_id"], + "volume_id": worldpack["arc_plans"][5]["volume_id"], + "related_character_ids": list(mid_scene["required_roles"]), + "related_characters": list(mid_scene["required_roles"]), + }, + { + "chapter_index": 85, + "chapter_title": "第85章", + "decision": "rewrite", + "severity": "watch", + "overall_score": 0.74, + "issue_count": 1, + "issue_codes": ["Q09"], + "scene_function": "truth_trial", + "scene_id": "", + "chapter_task_id": late_task["chapter_task_id"], + "arc_id": late_arc["arc_id"], + "volume_id": late_volume_id, + "related_character_ids": ["wen_xi", "gu_chenzhou"], + "related_characters": ["闻汐", "顾沉舟"], + }, + ] + version = repository.get_world_version(draft["world_version_id"]) + version.simulation_report_json = { + "chapter_budget": 100, + "longform_plan_snapshot": { + "series_plan": worldpack["series_plan"], + "volume_plans": worldpack["volume_plans"], + "arc_plans": worldpack["arc_plans"], + "chapter_budget_policy": worldpack["chapter_budget_policy"], + }, + "content_quality_contract_window_metrics": { + "enabled": True, + "early_window_q03_q04_share": 0.8, + "mid_window_repeat_breach_rate": 0.7, + "mid_window_exposition_breach_rate": 0.66, + "late_window_q09_breach_rate": 0.4, + "thresholds": { + "early_window_q03_q04_share_max": 0.45, + "mid_window_repeat_breach_rate_max": 0.30, + "mid_window_exposition_breach_rate_max": 0.30, + "late_window_q09_breach_rate_max": 0.08, + }, + "contract_failed_chapters": [ + {"chapter_id": "chapter_2", "chapter_index": 2, "failed_checks": ["repetition_score_cap", "exposition_ratio_cap"], "decision": "rewrite"}, + {"chapter_id": "chapter_35", "chapter_index": 35, "failed_checks": ["repetition_score_cap", "exposition_ratio_cap"], "decision": "rewrite"}, + {"chapter_id": "chapter_85", "chapter_index": 85, "failed_checks": ["q09_pre_end", "continuation_pressure_floor"], "decision": "rewrite"}, + ], + }, + "creative_cockpit": { + "chapter_heatmap": { + "chapters": chapter_heatmap, + "issue_priority_groups": authoring._build_issue_priority_groups(chapter_heatmap), + } + }, + "chapter_evaluations": [], + } + repository.save_world_version(version, publish=False) + + refreshed = authoring.get_draft(draft["world_version_id"]) + workbench = refreshed["content_quality_repair_workbench"] + campaigns = {(item["window_label"], item["issue_code"]): item for item in workbench["campaigns"]} + + assert workbench["available"] is True + assert refreshed["hard_constraint_status"] == "blocked" + assert refreshed["blocking_dimension"] == "Q09" + assert refreshed["window_breach_kind"] == "late_window_q09_breach_rate" + assert refreshed["ready_for_validation"] is False + assert workbench["default_campaign"]["window_label"] == "late" + assert workbench["default_campaign"]["issue_code"] == "Q09" + assert workbench["default_campaign"]["primary_asset_type"] == "chapter_task" + assert ("early", "Q03") in campaigns + assert ("early", "Q04") in campaigns + assert ("mid", "Q03") in campaigns + assert ("mid", "Q04") in campaigns + assert ("late", "Q09") in campaigns + assert [item["asset_type"] for item in campaigns[("early", "Q03")]["secondary_asset_targets"]] == [ + "scene_realization_contracts", + "emotion_action_policies", + ] + assert [item["asset_type"] for item in campaigns[("early", "Q04")]["secondary_asset_targets"]] == [ + "scene_realization_contracts", + "emotion_action_policies", + ] + assert campaigns[("mid", "Q03")]["secondary_asset_targets"][0]["target_label"] == "default::karma_ripening" + early_q03_paths = [item["path"] for item in campaigns[("early", "Q03")]["suggested_field_edits"]] + early_q04_paths = [item["path"] for item in campaigns[("early", "Q04")]["suggested_field_edits"]] + mid_q03_paths = [item["path"] for item in campaigns[("mid", "Q03")]["suggested_field_edits"]] + assert any(path.startswith('scene_realization_contracts["default"]') for path in early_q03_paths) + assert any(path.startswith('emotion_action_policies["default"]') for path in early_q03_paths) + assert any(path.startswith('scene_realization_contracts["default"]') for path in early_q04_paths) + assert any(path.startswith('emotion_action_policies["default"]') for path in early_q04_paths) + assert any(path.startswith('scene_realization_contracts["default"]') for path in mid_q03_paths) + assert campaigns[("early", "Q03")]["strategy_bundle_id"] == "q03_q04_scene_dialogue_cadence_task_coupling" + assert campaigns[("mid", "Q04")]["strategy_bundle"]["strategy_bundle_label"] == "Scene + Dialogue + Cadence + Task Coupling" + assert campaigns[("early", "Q03")]["strategy_bundle"]["execution_protocol_enabled"] is True + assert campaigns[("early", "Q03")]["strategy_bundle"]["bundle_step_planning"][0]["apply_order"] == 1 + assert campaigns[("early", "Q03")]["strategy_bundle"]["rerun_attribution"]["rerun_scope"] == "full_100_rerun" + assert campaigns[("early", "Q03")]["strategy_bundle"]["stop_condition"]["rule_id"] == "upgrade_to_planner_or_pack_contract_if_two_reruns_flat" + assert campaigns[("late", "Q09")]["primary_asset_target"]["chapter_task_id"] == late_task["chapter_task_id"] + assert campaigns[("late", "Q09")]["repair_loop_context"]["window_label"] == "late" + assert any( + edit["path"].endswith(".quality_contract.continuation_pressure_required") + for edit in campaigns[("late", "Q09")]["suggested_field_edits"] + ) + + +def test_repair_workbench_creates_preventive_q03_campaign_from_quality_pass_burden(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "preventive_q03_workbench.db")) + registry = FileSystemWorldRegistry() + authoring = AuthoringService(repository, registry=registry) + + worldpack = copy.deepcopy(registry.get_published_world("tide_archive_memory_debt")["worldpack"]) + simulation_report = { + "chapter_budget": 6, + "completed_chapters": 6, + "latest_decision": "pass", + "evaluation_summary": {"pass_rate": 1.0, "rewrite_rate": 0.0, "block_rate": 0.0}, + "content_quality_contract_window_metrics": { + "enabled": True, + "thresholds": {}, + "contract_failed_chapters": [], + }, + "longform_plan_snapshot": {"series_plan": {"total_chapter_target": 100}}, + "chapter_trace": [ + { + "chapter_id": "chapter_1", + "chapter_title": "第1章", + "scene_function": "false_peace", + "quality_pass_applied": True, + "quality_pass_actions": ["q03_final_repetition_replace:4", "q03_bundle_target_replace:6"], + } + ], + "chapter_evaluations": [ + { + "chapter_id": "chapter_1", + "decision": {"decision": "pass"}, + "scores": {"overall_score": 0.86, "pacing": 0.8, "hook_quality": 0.8, "scene_density": 0.8}, + "issues": [], + "hard_validator_results": { + "lint_metrics": { + "repetition_score": 0.15, + "exposition_ratio": 0.2, + "dialogue_plus_action_ratio": 0.62, + "concrete_detail_density": 0.5, + } + }, + } + ], + } + + workbench = authoring._build_content_quality_repair_workbench(worldpack, simulation_report) + + assert workbench["available"] is True + campaign = workbench["default_campaign"] + assert campaign["issue_code"] == "Q03" + assert campaign["breach_kind"] == "quality_pass_q03_repair_burden" + assert campaign["strategy_bundle"]["execution_protocol_enabled"] is True + assert campaign["repair_loop_context"]["preventive_quality_pass_campaign"] is True + assert campaign["repair_loop_context"]["baseline_quality_pass_q03_action_count"] == 2 + + +def test_strategy_bundle_executor_records_step_receipt_and_result_attribution(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "strategy_bundle_executor.db")) + registry = FileSystemWorldRegistry() + authoring = AuthoringService(repository, registry=registry) + seeded = _seed_tide_archive_strategy_bundle_draft(authoring, repository) + world_version_id = seeded["draft"]["world_version_id"] + baseline_report = copy.deepcopy(seeded["baseline_report"]) + + def fake_rerun(world_version_id_arg: str, **_: object) -> dict[str, object]: + assert world_version_id_arg == world_version_id + rerun_report = copy.deepcopy(baseline_report) + rerun_report["evaluation_summary"]["avg_repetition_score"] = 0.211 + rerun_report["evaluation_summary"]["dialogue_ratio"] = 0.43 + rerun_report["content_quality_contract_window_metrics"]["early_window_q03_q04_share"] = 0.42 + rerun_report["content_quality_contract_window_metrics"]["mid_window_repeat_breach_rate"] = 0.28 + rerun_report["content_quality_contract_window_metrics"]["mid_window_exposition_breach_rate"] = 0.31 + rerun_report["latest_repair_loop_outcome"] = { + "issue_code": "Q03", + "window_label": "early", + "window_breach_kind": "early_window_q03_q04_share", + "baseline_window_issue_count": 1, + "current_window_issue_count": 0, + "severity_trend": "improved", + "ready_for_validation": True, + } + version = repository.get_world_version(world_version_id_arg) + version.simulation_report_json = copy.deepcopy(rerun_report) + repository.save_world_version(version, publish=False) + return rerun_report + + authoring.run_simulation_for_world_version = fake_rerun # type: ignore[method-assign] + + updated = authoring.execute_content_quality_strategy_bundle( + world_version_id, + campaign_id="content_quality::early::Q03", + ) + + latest_execution = updated["latest_strategy_bundle_execution"] + assert latest_execution["strategy_bundle_id"] == "q03_q04_scene_dialogue_cadence_task_coupling" + assert latest_execution["step_level_apply_receipt"] + assert latest_execution["step_level_apply_receipt"][0]["apply_order"] == 1 + assert latest_execution["step_level_apply_receipt"][0]["asset_type"] == "scene_blueprint" + assert latest_execution["step_level_apply_receipt"][0]["status"] == "applied" + assert latest_execution["applied_edit_count"] > 0 + assert latest_execution["result_attribution"]["overall_status"] == "improved" + assert "early_window_q03_q04_share" in latest_execution["result_attribution"]["improved_metrics"] + assert latest_execution["stop_decision"]["decision"] == "stop" + assert updated["strategy_bundle_execution_history"] + assert updated["worldpack"]["scene_blueprints"][0]["quality_contract"]["variation_axes"] == [ + "voice", + "movement", + "object_state", + "information_reveal", + "consequence", + ] + + +def test_strategy_bundle_executor_escalates_after_second_flat_rerun(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "strategy_bundle_executor_escalate.db")) + registry = FileSystemWorldRegistry() + authoring = AuthoringService(repository, registry=registry) + seeded = _seed_tide_archive_strategy_bundle_draft(authoring, repository) + world_version_id = seeded["draft"]["world_version_id"] + baseline_report = copy.deepcopy(seeded["baseline_report"]) + + def flat_rerun(world_version_id_arg: str, **_: object) -> dict[str, object]: + assert world_version_id_arg == world_version_id + rerun_report = copy.deepcopy(baseline_report) + rerun_report["latest_repair_loop_outcome"] = { + "issue_code": "Q03", + "window_label": "early", + "window_breach_kind": "early_window_q03_q04_share", + "baseline_window_issue_count": 1, + "current_window_issue_count": 1, + "severity_trend": "flat", + "ready_for_validation": False, + } + version = repository.get_world_version(world_version_id_arg) + version.simulation_report_json = copy.deepcopy(rerun_report) + repository.save_world_version(version, publish=False) + return rerun_report + + authoring.run_simulation_for_world_version = flat_rerun # type: ignore[method-assign] + + first = authoring.execute_content_quality_strategy_bundle( + world_version_id, + campaign_id="content_quality::early::Q03", + ) + assert first["latest_strategy_bundle_execution"]["stop_decision"]["decision"] == "continue" + + second = authoring.execute_content_quality_strategy_bundle( + world_version_id, + campaign_id="content_quality::early::Q03", + ) + assert second["latest_strategy_bundle_execution"]["stop_decision"]["decision"] == "escalate" + assert second["latest_strategy_bundle_execution"]["stop_decision"]["escalation_target"] == "planner_or_pack_contract" + + +def test_author_api_can_execute_strategy_bundle_and_return_receipt(tmp_path: Path): + app = create_app(repository=SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_strategy_bundle_api.db"))) + client = TestClient(app) + + reviewer_headers = _auth_headers(client, actor_id="ops_reviewer_strategy", actor_role="reviewer", password="secret123") + author_headers = _auth_headers(client, actor_id="acct_author_strategy", actor_role="author", password="secret123") + + client.post( + "/v1/ops/subscriptions/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_strategy", "tier_id": "creator_pass", "provider": "ops_manual", "status": "active"}, + ) + client.post( + "/v1/ops/wallets/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_strategy", "wallet_type": "studio_credits", "amount": 10}, + ) + + seeded = _seed_tide_archive_strategy_bundle_draft( + app.state.authoring_service, + app.state.repository, + author_id="acct_author_strategy", + ) + world_version_id = seeded["draft"]["world_version_id"] + baseline_report = copy.deepcopy(seeded["baseline_report"]) + starting_entitlements = client.get( + "/v1/reader/entitlements", + headers=author_headers, + params={"account_id": "acct_author_strategy"}, + ) + assert starting_entitlements.status_code == 200 + starting_balance = starting_entitlements.json()["wallets"]["studio_credits"]["balance"] + + def fake_rerun(world_version_id_arg: str, **_: object) -> dict[str, object]: + rerun_report = copy.deepcopy(baseline_report) + rerun_report["evaluation_summary"]["avg_repetition_score"] = 0.23 + rerun_report["evaluation_summary"]["dialogue_ratio"] = 0.41 + rerun_report["content_quality_contract_window_metrics"]["early_window_q03_q04_share"] = 0.44 + rerun_report["latest_repair_loop_outcome"] = { + "issue_code": "Q03", + "window_label": "early", + "window_breach_kind": "early_window_q03_q04_share", + "baseline_window_issue_count": 1, + "current_window_issue_count": 0, + "severity_trend": "improved", + "ready_for_validation": True, + } + version = app.state.repository.get_world_version(world_version_id_arg) + version.simulation_report_json = copy.deepcopy(rerun_report) + app.state.repository.save_world_version(version, publish=False) + return rerun_report + + app.state.authoring_service.run_simulation_for_world_version = fake_rerun # type: ignore[method-assign] + + response = client.post( + f"/v1/author/drafts/{world_version_id}/strategy-bundles/execute", + headers=author_headers, + json={"campaign_id": "content_quality::early::Q03", "account_id": "acct_author_strategy"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["latest_strategy_bundle_execution"]["campaign_id"] == "content_quality::early::Q03" + assert payload["latest_strategy_bundle_execution"]["step_level_apply_receipt"] + assert payload["latest_strategy_bundle_execution"]["result_attribution"]["overall_status"] == "improved" + assert payload["strategy_bundle_execution_history"] + + entitlements = client.get( + "/v1/reader/entitlements", + headers=author_headers, + params={"account_id": "acct_author_strategy"}, + ) + assert entitlements.status_code == 200 + assert entitlements.json()["wallets"]["studio_credits"]["balance"] == starting_balance - 1 + + +def test_author_work_flow_supports_generate_edit_diagnostics_and_submit(tmp_path: Path): + app = create_app(repository=SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_work_flow.db"))) + client = TestClient(app) + + reviewer_headers = _auth_headers(client, actor_id="ops_reviewer_work", actor_role="reviewer", password="secret123") + author_headers = _auth_headers(client, actor_id="acct_author_work", actor_role="author", password="secret123") + + client.post( + "/v1/ops/subscriptions/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_work", "tier_id": "creator_pass", "provider": "ops_manual", "status": "active"}, + ) + client.post( + "/v1/ops/wallets/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_work", "wallet_type": "studio_credits", "amount": 10}, + ) + + draft = client.post( + "/v1/author/drafts/from-brief", + headers=author_headers, + json={ + "brief": { + "genre_preset": "urban_mystery", + "world_title": "质量长章测试", + "lead_name": "沈砚", + "counterpart_name": "林潮", + "core_premise": "一个在临港旧区整理遗物的年轻档案修复师,意外收到一卷属于失踪旧案的录音带。他必须在潮水、旧街改造和关系背叛之间,查清多年前被掩埋的真相。", + "life_theme": "真相是否值得付出关系的代价", + "locations": "临港旧区\n潮渠边档案仓\n停运渡口\n废弃录音棚", + "author_id": "acct_author_work", + "target_total_chapters": 24, + "target_total_volumes": 3, + "target_word_count": 48000, + }, + "account_id": "acct_author_work", + }, + ) + assert draft.status_code == 200 + world_version_id = draft.json()["world_version_id"] + + created_work = client.post( + "/v1/author/works", + headers=author_headers, + json={"world_version_id": world_version_id, "account_id": "acct_author_work"}, + ) + assert created_work.status_code == 200 + work_id = created_work.json()["work_id"] + + generated_first = client.post( + f"/v1/author/works/{work_id}/chapters/generate", + headers=author_headers, + json={"mode": "first", "account_id": "acct_author_work"}, + ) + assert generated_first.status_code == 200 + assert generated_first.json()["chapter_count"] >= 1 + assert 1800 <= story_text_unit_count(generated_first.json()["chapters"][0]["body"]) <= 2200 + + generated_next = client.post( + f"/v1/author/works/{work_id}/chapters/generate", + headers=author_headers, + json={"mode": "next", "account_id": "acct_author_work"}, + ) + assert generated_next.status_code == 200 + assert generated_next.json()["chapter_count"] >= 2 + assert 1800 <= story_text_unit_count(generated_next.json()["chapters"][-1]["body"]) <= 2200 + + chapter = client.get( + f"/v1/author/works/{work_id}/chapters/1", + headers=author_headers, + ) + assert chapter.status_code == 200 + assert chapter.json()["chapter"]["body"] + assert "chapter_task" in chapter.json()["chapter"] + assert "latest_diagnostic_summary" in chapter.json()["chapter"] + + edited = client.post( + f"/v1/author/works/{work_id}/chapters/1/edit", + headers=author_headers, + json={ + "chapter_title": "第 1 章 · 作者手改版", + "body": chapter.json()["chapter"]["body"] + "\n\n这一次,他先确认的不只是长度,而是每一段是否真的推进了关系与真相。", + "summary": "作者手工修订了开场,并补强了继续阅读压力。", + "account_id": "acct_author_work", + }, + ) + assert edited.status_code == 200 + assert edited.json()["chapter"]["source_type"] == "manual_edit" + + diagnostics = client.post( + f"/v1/author/works/{work_id}/diagnostics/run", + headers=author_headers, + json={"account_id": "acct_author_work"}, + ) + assert diagnostics.status_code == 200 + assert "evaluation_summary" in diagnostics.json() + assert diagnostics.json()["work"]["diagnostics_summary"]["evaluation_summary"]["block_rate"] >= 0.0 + assert diagnostics.json()["work"]["diagnostics_summary"]["latest_decision"] == "pass" + assert diagnostics.json()["work"]["status"] == "review_ready" + chapter_lengths = [ + story_text_unit_count(item["body"]) + for item in diagnostics.json()["work"]["chapters"] + ] + assert all(1800 <= length <= 2200 for length in chapter_lengths) + assert diagnostics.json()["work"]["diagnostics_summary"]["evaluation_summary"]["rewrite_rate"] == 0.0 + for chapter_payload in diagnostics.json()["work"]["chapters"]: + issue_codes = set(chapter_payload["latest_diagnostic_summary"]["issue_codes"]) + assert "Q03" not in issue_codes + assert "Q09" not in issue_codes + + submitted = client.post( + f"/v1/author/works/{work_id}/submit", + headers=author_headers, + json={"account_id": "acct_author_work"}, + ) + assert submitted.status_code == 200 + assert submitted.json()["status"] == "submitted" + + +def test_author_work_can_create_parallel_universe_branch_without_overwriting_mainline(tmp_path: Path): + app = create_app(repository=SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_work_branching.db"))) + client = TestClient(app) + + reviewer_headers = _auth_headers(client, actor_id="ops_reviewer_branch", actor_role="reviewer", password="secret123") + author_headers = _auth_headers(client, actor_id="acct_author_branch", actor_role="author", password="secret123") + + client.post( + "/v1/ops/subscriptions/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_branch", "tier_id": "creator_pass", "provider": "ops_manual", "status": "active"}, + ) + client.post( + "/v1/ops/wallets/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_branch", "wallet_type": "studio_credits", "amount": 20}, + ) + + draft = client.post( + "/v1/author/drafts/from-brief", + headers=author_headers, + json={ + "brief": { + "genre_preset": "xianxia", + "world_title": "平行宇宙分支测试", + "lead_name": "沈照", + "counterpart_name": "叶青烛", + "core_premise": "验证作者作品分支能力。", + "life_theme": "命运线分叉", + "author_id": "acct_author_branch", + "target_total_chapters": 12, + "target_total_volumes": 2, + "target_word_count": 24000, + }, + "account_id": "acct_author_branch", + }, + ) + world_version_id = draft.json()["world_version_id"] + work = client.post( + "/v1/author/works", + headers=author_headers, + json={"world_version_id": world_version_id, "account_id": "acct_author_branch"}, + ) + work_id = work.json()["work_id"] + + first = client.post( + f"/v1/author/works/{work_id}/chapters/generate", + headers=author_headers, + json={"mode": "first", "account_id": "acct_author_branch"}, + ) + assert first.status_code == 200 + second = client.post( + f"/v1/author/works/{work_id}/chapters/generate", + headers=author_headers, + json={"mode": "next", "account_id": "acct_author_branch"}, + ) + assert second.status_code == 200 + + mainline_before = client.get(f"/v1/author/works/{work_id}", headers=author_headers).json() + mainline_revision_before = mainline_before["current_revision"] + chapter_one_before = client.get( + f"/v1/author/works/{work_id}/chapters/1", + headers=author_headers, + ).json()["chapter"]["body"] + + branched = client.post( + f"/v1/author/works/{work_id}/branches", + headers=author_headers, + json={ + "source_chapter_index": 1, + "steering_directive": { + "current_user_intent": "让主角从这一章之后走向另一种代价。", + "summary": "让主角从这一章之后走向另一种代价。", + "impacted_character_ids": ["lead", "counterpart"], + }, + "choice_source": "先顺着诱惑往前探一步,看代价会先落到谁身上。", + "account_id": "acct_author_branch", + }, + ) + assert branched.status_code == 200 + branch_payload = branched.json() + assert branch_payload["branch_kind"] == "parallel_universe" + assert branch_payload["parent_work_id"] == work_id + assert branch_payload["root_work_id"] == work_id + assert branch_payload["fork_after_chapter_index"] == 1 + assert branch_payload["chapter_count"] == 1 + assert branch_payload["branch_origin_label"] == "先顺着诱惑往前探一步,看代价会先落到谁身上。" + assert branch_payload["is_active_line"] is True + assert any(item["branch_kind"] == "mainline" and item["is_active_line"] is False for item in branch_payload["branch_family"]) + assert any(item["work_id"] == branch_payload["work_id"] and item["is_active_line"] is True for item in branch_payload["branch_family"]) + + branch_chapter_one = client.get( + f"/v1/author/works/{branch_payload['work_id']}/chapters/1", + headers=author_headers, + ) + assert branch_chapter_one.status_code == 200 + assert branch_chapter_one.json()["chapter"]["body"] == chapter_one_before + + family = client.get( + f"/v1/author/works/{branch_payload['work_id']}/branches", + headers=author_headers, + ) + assert family.status_code == 200 + assert len(family.json()["branch_family"]) >= 2 + assert any(item["branch_kind"] == "mainline" for item in family.json()["branch_family"]) + assert any(item["branch_kind"] == "parallel_universe" for item in family.json()["branch_family"]) + + branch_next = client.post( + f"/v1/author/works/{branch_payload['work_id']}/chapters/generate", + headers=author_headers, + json={"mode": "next", "account_id": "acct_author_branch"}, + ) + assert branch_next.status_code == 200 + assert branch_next.json()["chapter_count"] == 2 + + activated_mainline = client.post( + f"/v1/author/works/{work_id}/activate-line", + headers=author_headers, + json={"account_id": "acct_author_branch"}, + ) + assert activated_mainline.status_code == 200 + assert activated_mainline.json()["is_active_line"] is True + assert any(item["work_id"] == work_id and item["is_active_line"] is True for item in activated_mainline.json()["branch_family"]) + assert any(item["work_id"] == branch_payload["work_id"] and item["is_active_line"] is False for item in activated_mainline.json()["branch_family"]) + + mainline_after = client.get(f"/v1/author/works/{work_id}", headers=author_headers).json() + assert mainline_after["chapter_count"] == 2 + assert mainline_after["current_revision"] == mainline_revision_before + + +def test_author_work_branch_discards_mainline_future_chapters_after_selected_fork_point(tmp_path: Path): + app = create_app(repository=SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_work_branch_future_cut.db"))) + client = TestClient(app) + + reviewer_headers = _auth_headers(client, actor_id="ops_reviewer_future", actor_role="reviewer", password="secret123") + author_headers = _auth_headers(client, actor_id="acct_author_future", actor_role="author", password="secret123") + + client.post( + "/v1/ops/subscriptions/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_future", "tier_id": "creator_pass", "provider": "ops_manual", "status": "active"}, + ) + client.post( + "/v1/ops/wallets/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_future", "wallet_type": "studio_credits", "amount": 40}, + ) + + draft = client.post( + "/v1/author/drafts/from-brief", + headers=author_headers, + json={ + "brief": { + "genre_preset": "xianxia", + "world_title": "未来章节裁剪测试", + "lead_name": "沈照", + "counterpart_name": "叶青烛", + "core_premise": "验证 steering 只影响未来章节。", + "life_theme": "命运线分叉", + "author_id": "acct_author_future", + "target_total_chapters": 30, + "target_total_volumes": 3, + "target_word_count": 60000, + }, + "account_id": "acct_author_future", + }, + ) + world_version_id = draft.json()["world_version_id"] + work = client.post( + "/v1/author/works", + headers=author_headers, + json={"world_version_id": world_version_id, "account_id": "acct_author_future"}, + ) + work_id = work.json()["work_id"] + + repository = app.state.repository + for chapter_index in range(1, 31): + repository.save_author_work_chapter( + { + "work_id": work_id, + "chapter_index": chapter_index, + "chapter_title": f"第 {chapter_index} 章", + "body": f"第 {chapter_index} 章正文,保持中文内容。", + "status": "generated", + "source_type": "generated", + "summary": f"第 {chapter_index} 章摘要", + "choices_json": ["继续", "停下"], + "state_snapshot_json": _simple_state(chapter_index).to_dict(), + } + ) + seeded_work = repository.get_author_work(work_id) + repository.save_author_work( + { + **seeded_work, + "chapter_count": 30, + "narrative_state_json": _simple_state(30).to_dict(), + } + ) + + branched = client.post( + f"/v1/author/works/{work_id}/branches", + headers=author_headers, + json={ + "source_chapter_index": 7, + "steering_directive": { + "current_user_intent": "从第七章之后让命运转向另一条路。", + "summary": "从第七章之后让命运转向另一条路。", + "impacted_character_ids": ["lead", "counterpart"], + }, + "choice_source": "先看清这一剑往谁身上落。", + "account_id": "acct_author_future", + }, + ) + assert branched.status_code == 200 + branch_payload = branched.json() + assert branch_payload["fork_after_chapter_index"] == 7 + assert branch_payload["chapter_count"] == 7 + assert len(branch_payload["chapters"]) == 7 + assert branch_payload["chapters"][-1]["chapter_index"] == 7 + + mainline = client.get(f"/v1/author/works/{work_id}", headers=author_headers) + assert mainline.status_code == 200 + assert mainline.json()["chapter_count"] == 30 + + +def test_author_work_delete_removes_entire_work_family_for_owner_only(tmp_path: Path): + app = create_app(repository=SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_work_delete_family.db"))) + client = TestClient(app) + + reviewer_headers = _auth_headers(client, actor_id="ops_reviewer_delete", actor_role="reviewer", password="secret123") + author_headers = _auth_headers(client, actor_id="acct_author_delete", actor_role="author", password="secret123") + other_author_headers = _auth_headers(client, actor_id="acct_author_other", actor_role="author", password="secret123") + + client.post( + "/v1/ops/subscriptions/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_delete", "tier_id": "creator_pass", "provider": "ops_manual", "status": "active"}, + ) + client.post( + "/v1/ops/wallets/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_delete", "wallet_type": "studio_credits", "amount": 20}, + ) + + draft = client.post( + "/v1/author/drafts/from-brief", + headers=author_headers, + json={ + "brief": { + "genre_preset": "xianxia", + "world_title": "删除作品测试", + "lead_name": "沈照", + "counterpart_name": "叶青烛", + "core_premise": "验证作者可以删除自己的作品族。", + "life_theme": "删除后不留残影", + "author_id": "acct_author_delete", + "target_total_chapters": 12, + "target_total_volumes": 2, + "target_word_count": 24000, + }, + "account_id": "acct_author_delete", + }, + ) + world_version_id = draft.json()["world_version_id"] + work = client.post( + "/v1/author/works", + headers=author_headers, + json={"world_version_id": world_version_id, "account_id": "acct_author_delete"}, + ) + work_id = work.json()["work_id"] + + first = client.post( + f"/v1/author/works/{work_id}/chapters/generate", + headers=author_headers, + json={"mode": "first", "account_id": "acct_author_delete"}, + ) + assert first.status_code == 200 + + branch = client.post( + f"/v1/author/works/{work_id}/branches", + headers=author_headers, + json={ + "source_chapter_index": 1, + "steering_directive": { + "current_user_intent": "从这一章后改写命运。", + "summary": "从这一章后改写命运。", + "impacted_character_ids": ["lead", "counterpart"], + }, + "choice_source": "先把真相压后一步。", + "account_id": "acct_author_delete", + }, + ) + assert branch.status_code == 200 + branch_work_id = branch.json()["work_id"] + + forbidden = client.delete( + f"/v1/author/works/{work_id}", + headers=other_author_headers, + ) + assert forbidden.status_code == 403 + + deleted = client.delete( + f"/v1/author/works/{work_id}", + headers=author_headers, + ) + assert deleted.status_code == 200 + deleted_payload = deleted.json() + assert deleted_payload["deleted_root_work_id"] == work_id + assert deleted_payload["deleted_work_count"] == 2 + assert set(deleted_payload["deleted_work_ids"]) == {work_id, branch_work_id} + assert deleted_payload["deleted_chapter_count"] >= 2 + assert deleted_payload["deleted_revision_count"] >= 2 + + works_after = client.get( + "/v1/author/works?account_id=acct_author_delete", + headers=author_headers, + ) + assert works_after.status_code == 200 + assert works_after.json()["works"] == [] + + repository = app.state.repository + for deleted_work_id in deleted_payload["deleted_work_ids"]: + try: + repository.get_author_work(deleted_work_id) + assert False, f"expected deleted work to be gone: {deleted_work_id}" + except KeyError: + pass + assert repository.list_author_work_chapters(work_id=deleted_work_id) == [] + assert repository.list_author_work_revisions(work_id=deleted_work_id) == [] + + +def test_author_work_quality_gate_blocks_short_generation_and_manual_edit(tmp_path: Path, monkeypatch): + app = create_app(repository=SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_work_quality_gate.db"))) + client = TestClient(app) + + reviewer_headers = _auth_headers(client, actor_id="ops_reviewer_guard", actor_role="reviewer", password="secret123") + author_headers = _auth_headers(client, actor_id="acct_author_guard", actor_role="author", password="secret123") + + client.post( + "/v1/ops/subscriptions/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_guard", "tier_id": "creator_pass", "provider": "ops_manual", "status": "active"}, + ) + client.post( + "/v1/ops/wallets/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_guard", "wallet_type": "studio_credits", "amount": 10}, + ) + + draft = client.post( + "/v1/author/drafts/from-brief", + headers=author_headers, + json={ + "brief": { + "genre_preset": "urban_mystery", + "world_title": "章节硬约束测试", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "短章不能入库。", + "life_theme": "质量先于入库", + "author_id": "acct_author_guard", + "target_total_chapters": 12, + "target_total_volumes": 2, + "target_word_count": 24000, + }, + "account_id": "acct_author_guard", + }, + ) + world_version_id = draft.json()["world_version_id"] + work = client.post( + "/v1/author/works", + headers=author_headers, + json={"world_version_id": world_version_id, "account_id": "acct_author_guard"}, + ) + work_id = work.json()["work_id"] + + import src.narrativeos.services.author_work as author_work_module + + original_plan_next_turn = author_work_module.plan_next_turn + + def short_plan_next_turn(*args, **kwargs): + result = original_plan_next_turn(*args, **kwargs) + result["reader_view"]["body"] = "他停了一下。\\n\\n“先别说。”" + result["reader_view"]["chapter_title"] = "第 1 章 · 过短短章" + return result + + monkeypatch.setattr(author_work_module, "plan_next_turn", short_plan_next_turn) + blocked_generate = client.post( + f"/v1/author/works/{work_id}/chapters/generate", + headers=author_headers, + json={"mode": "first", "account_id": "acct_author_guard"}, + ) + assert blocked_generate.status_code == 400 + assert blocked_generate.json()["detail"]["code"] == "chapter_quality_guard_failed" + assert blocked_generate.json()["detail"]["quality_gate"]["required_text_units"] >= 1800 + + work_detail = client.get(f"/v1/author/works/{work_id}", headers=author_headers) + assert work_detail.status_code == 200 + assert work_detail.json()["chapter_count"] == 0 + assert "content_quality_repair_workbench" in work_detail.json() + assert all(rev["revision_type"] != "generation" for rev in work_detail.json()["revisions"][:1]) + assert any(rev["revision_type"] == "quality_guard_blocked" for rev in work_detail.json()["revisions"]) + + monkeypatch.setattr(author_work_module, "plan_next_turn", original_plan_next_turn) + generated = client.post( + f"/v1/author/works/{work_id}/chapters/generate", + headers=author_headers, + json={"mode": "first", "account_id": "acct_author_guard"}, + ) + assert generated.status_code == 200 + + chapter_before = client.get( + f"/v1/author/works/{work_id}/chapters/1", + headers=author_headers, + ).json()["chapter"]["body"] + work_before_edit = client.get(f"/v1/author/works/{work_id}", headers=author_headers).json()["current_revision"] + + blocked_edit = client.post( + f"/v1/author/works/{work_id}/chapters/1/edit", + headers=author_headers, + json={ + "chapter_title": "第 1 章 · 过短手工稿", + "body": "他看着她。\\n\\n“算了。”", + "summary": "太短了。", + "account_id": "acct_author_guard", + }, + ) + assert blocked_edit.status_code == 400 + assert blocked_edit.json()["detail"]["code"] == "chapter_quality_guard_failed" + + chapter_after = client.get( + f"/v1/author/works/{work_id}/chapters/1", + headers=author_headers, + ).json()["chapter"]["body"] + work_after = client.get(f"/v1/author/works/{work_id}", headers=author_headers).json() + assert chapter_after == chapter_before + assert work_after["current_revision"] == work_before_edit + assert any(rev["revision_type"] == "quality_guard_blocked" for rev in work_after["revisions"]) + + +def test_author_work_quality_gate_blocks_disallowed_latin_tokens_in_generation_and_manual_edit(tmp_path: Path, monkeypatch): + app = create_app(repository=SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_work_latin_guard.db"))) + client = TestClient(app) + + reviewer_headers = _auth_headers(client, actor_id="ops_reviewer_latin", actor_role="reviewer", password="secret123") + author_headers = _auth_headers(client, actor_id="acct_author_latin", actor_role="author", password="secret123") + + client.post( + "/v1/ops/subscriptions/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_latin", "tier_id": "creator_pass", "provider": "ops_manual", "status": "active"}, + ) + client.post( + "/v1/ops/wallets/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_latin", "wallet_type": "studio_credits", "amount": 10}, + ) + + draft = client.post( + "/v1/author/drafts/from-brief", + headers=author_headers, + json={ + "brief": { + "genre_preset": "urban_mystery", + "world_title": "英文硬约束测试", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "reader 可见文本不允许出现普通英文。", + "life_theme": "中文可见面必须稳定", + "author_id": "acct_author_latin", + "target_total_chapters": 12, + "target_total_volumes": 2, + "target_word_count": 24000, + }, + "account_id": "acct_author_latin", + }, + ) + world_version_id = draft.json()["world_version_id"] + work = client.post( + "/v1/author/works", + headers=author_headers, + json={"world_version_id": world_version_id, "account_id": "acct_author_latin"}, + ) + work_id = work.json()["work_id"] + + import src.narrativeos.services.author_work as author_work_module + + original_plan_next_turn = author_work_module.plan_next_turn + + def latin_plan_next_turn(*args, **kwargs): + result = original_plan_next_turn(*args, **kwargs) + result["reader_view"]["body"] = f"{result['reader_view']['body']}\n\n她把 obedience 压回沉默里,却没法把这一步重新说轻。" + result["reader_view"]["recap"] = "前情提要:她把 temptation 压回了沉默里。" + result["reader_view"]["relationship_hints"] = ["甲对乙多了一点 temptation。"] + return result + + monkeypatch.setattr(author_work_module, "plan_next_turn", latin_plan_next_turn) + generated_with_sanitization = client.post( + f"/v1/author/works/{work_id}/chapters/generate", + headers=author_headers, + json={"mode": "first", "account_id": "acct_author_latin"}, + ) + assert generated_with_sanitization.status_code == 200 + chapter_generated = app.state.repository.get_author_work_chapter(work_id=work_id, chapter_index=1) + assert "obedience" not in chapter_generated["body"] + + monkeypatch.setattr(author_work_module, "plan_next_turn", original_plan_next_turn) + chapter_before = client.get( + f"/v1/author/works/{work_id}/chapters/1", + headers=author_headers, + ).json()["chapter"]["body"] + + blocked_edit = client.post( + f"/v1/author/works/{work_id}/chapters/1/edit", + headers=author_headers, + json={ + "chapter_title": "第 1 章 · temptation 手工稿", + "body": chapter_before, + "summary": "标题里不应再混入英文。", + "account_id": "acct_author_latin", + }, + ) + assert blocked_edit.status_code == 400 + assert blocked_edit.json()["detail"]["code"] == "chapter_quality_guard_failed" + assert blocked_edit.json()["detail"]["quality_gate"]["latin_token_fields"] == ["chapter_title"] + + +def test_draft_from_brief_preserves_explicit_user_values(tmp_path: Path): + app = create_app(repository=SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_brief_values.db"))) + client = TestClient(app) + + reviewer_headers = _auth_headers(client, actor_id="ops_reviewer_brief", actor_role="reviewer", password="secret123") + author_headers = _auth_headers(client, actor_id="acct_author_brief", actor_role="author", password="secret123") + + client.post( + "/v1/ops/subscriptions/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_brief", "tier_id": "creator_pass", "provider": "ops_manual", "status": "active"}, + ) + client.post( + "/v1/ops/wallets/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_brief", "wallet_type": "studio_credits", "amount": 10}, + ) + + draft = client.post( + "/v1/author/drafts/from-brief", + headers=author_headers, + json={ + "brief": { + "genre_preset": "urban_mystery", + "world_title": "雾港回潮测试", + "lead_name": "沈砚", + "counterpart_name": "林潮", + "supporting_name": "许织", + "core_premise": "作者输入的 brief 必须保留下来,不能被 preset 覆盖。", + "life_theme": "真相是否值得付出关系的代价", + "locations": "临港旧区\n潮渠边档案仓\n停运渡口", + "author_id": "acct_author_brief", + "account_id": "acct_author_brief", + }, + }, + ) + assert draft.status_code == 200 + world_version_id = draft.json()["world_version_id"] + detail = client.get(f"/v1/author/drafts/{world_version_id}", headers=author_headers) + assert detail.status_code == 200 + worldpack = detail.json()["worldpack"] + lead = next(item for item in worldpack["characters"] if item["character_id"] == "lead") + counterpart = next(item for item in worldpack["characters"] if item["character_id"] == "counterpart") + + assert worldpack["title"] == "雾港回潮测试" + assert worldpack["world_bible"]["premise"] == "作者输入的 brief 必须保留下来,不能被 preset 覆盖。" + assert lead["display_name"] == "沈砚" + assert counterpart["display_name"] == "林潮" + assert lead["destiny_contract"]["life_theme"] == "真相是否值得付出关系的代价" + assert worldpack["metadata"]["author_brief"]["world_title"] == "雾港回潮测试" + + +def test_author_simulate_accepts_optional_steering_payload_and_exposes_creative_cockpit(tmp_path: Path): + app = create_app(repository=SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_simulate_steering.db"))) + client = TestClient(app) + + reviewer_headers = _auth_headers(client, actor_id="ops_reviewer_steering", actor_role="reviewer", password="secret123") + author_headers = _auth_headers(client, actor_id="acct_author_steering", actor_role="author", password="secret123") + + client.post( + "/v1/ops/subscriptions/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_steering", "tier_id": "creator_pass", "provider": "ops_manual", "status": "active"}, + ) + client.post( + "/v1/ops/wallets/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_steering", "wallet_type": "studio_credits", "amount": 10}, + ) + + draft = client.post( + "/v1/author/drafts/from-brief", + headers=author_headers, + json={ + "brief": { + "genre_preset": "synthetic", + "world_title": "作者 Steering 驾驶舱测试", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "验证 author simulate 可接受 steering payload。", + "life_theme": "作者可以直接扭转下一轮剧情方向", + "author_id": "acct_author_steering", + "target_total_chapters": 12, + "target_total_volumes": 3, + "target_word_count": 24000, + }, + "account_id": "acct_author_steering", + }, + ) + assert draft.status_code == 200 + world_version_id = draft.json()["world_version_id"] + entitlements_before = client.get( + "/v1/reader/entitlements", + headers=author_headers, + params={"account_id": "acct_author_steering"}, + ) + assert entitlements_before.status_code == 200 + starting_balance = entitlements_before.json()["wallets"]["studio_credits"]["balance"] + + legacy = client.post( + f"/v1/author/drafts/{world_version_id}/simulate", + headers=author_headers, + ) + assert legacy.status_code == 200 + assert "creative_cockpit" in legacy.json() + + steered = client.post( + f"/v1/author/drafts/{world_version_id}/simulate", + headers=author_headers, + json={ + "account_id": "acct_author_steering", + "interactive_scenarios": [ + { + "scenario_kind": "memory_steer", + "label": "旧誓突然回潮", + "steering_directive": { + "current_user_intent": "让主角在下一章因为旧誓而收住真话。", + "summary": "让主角在下一章因为旧誓而收住真话。", + "memory_patch_note": "主角突然想起旧誓的细节,因而不敢一次说尽。", + "impacted_character_ids": ["lead"], + }, + } + ], + }, + ) + assert steered.status_code == 200 + steered_payload = steered.json() + assert steered_payload["steering_checkpoints"] + assert steered_payload["creative_cockpit"]["available"] is True + assert steered_payload["creative_cockpit"]["steering_timeline"]["checkpoint_count"] == 1 + assert "relationship_network" in steered_payload["creative_cockpit"] + assert "scene_id" in steered_payload["creative_cockpit"]["chapter_heatmap"]["chapters"][0] + assert "chapter_task_id" in steered_payload["creative_cockpit"]["chapter_heatmap"]["chapters"][0] + assert "issue_priority_groups" in steered_payload["creative_cockpit"]["chapter_heatmap"] + + detail = client.get(f"/v1/author/drafts/{world_version_id}", headers=author_headers) + assert detail.status_code == 200 + assert detail.json()["creative_cockpit"]["steering_timeline"]["checkpoint_count"] == 1 + + repair_detail = client.get(f"/v1/author/drafts/{world_version_id}", headers=author_headers) + assert repair_detail.status_code == 200 + worldpack = repair_detail.json()["worldpack"] + worldpack["scene_blueprints"][0]["beats_template"].append("让环境细节承担更多压迫感。") + saved = client.put( + f"/v1/author/drafts/{world_version_id}", + headers=author_headers, + json={ + "worldpack": worldpack, + "account_id": "acct_author_steering", + "change_context": { + "source": "scene_editor", + "label": "保存场景蓝图", + "repair_loop_context": { + "issue_code": "Q05", + "issue_label": "lack of scene detail", + "asset_type": "scene_blueprint", + "asset_label": "场景蓝图", + "target_label": worldpack["scene_blueprints"][0]["scene_id"], + "validation_panel": "compare", + "validation_panel_label": "Compare", + "validation_reason": "改完 scene 后回 Compare 看前后章节差异。", + "scene_id": worldpack["scene_blueprints"][0]["scene_id"], + "scene_function": worldpack["scene_blueprints"][0]["scene_function"], + "chapter_index": 1, + "chapter_title": "第1章", + "targeted_chapters": [{"chapter_index": 1, "chapter_title": "第1章"}], + }, + }, + }, + ) + assert saved.status_code == 200 + assert saved.json()["revision_history"][-1]["repair_loop_context"]["issue_code"] == "Q05" + + rerun = client.post( + f"/v1/author/drafts/{world_version_id}/simulate", + headers=author_headers, + ) + assert rerun.status_code == 200 + assert "latest_repair_loop_outcome" in rerun.json() + + repaired_detail = client.get(f"/v1/author/drafts/{world_version_id}", headers=author_headers) + assert repaired_detail.status_code == 200 + assert "latest_repair_loop_outcome" in repaired_detail.json() + assert repaired_detail.json()["repair_loop_history"] + + entitlements = client.get( + "/v1/reader/entitlements", + headers=author_headers, + params={"account_id": "acct_author_steering"}, + ) + assert entitlements.status_code == 200 + assert entitlements.json()["wallets"]["studio_credits"]["balance"] == starting_balance - 3 + + +def test_author_simulate_summary_only_returns_lightweight_remote_payload(tmp_path: Path, monkeypatch): + app = create_app(repository=SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_simulate_summary.db"))) + client = TestClient(app) + + reviewer_headers = _auth_headers(client, actor_id="ops_reviewer_summary", actor_role="reviewer", password="secret123") + author_headers = _auth_headers(client, actor_id="acct_author_summary", actor_role="author", password="secret123") + client.post( + "/v1/ops/subscriptions/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_summary", "tier_id": "creator_pass", "provider": "ops_manual", "status": "active"}, + ) + client.post( + "/v1/ops/wallets/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_summary", "wallet_type": "studio_credits", "amount": 10}, + ) + draft = client.post( + "/v1/author/drafts/from-brief", + headers=author_headers, + json={ + "brief": { + "genre_preset": "synthetic", + "world_title": "作者远端轻量模拟测试", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "验证远端 smoke 可使用轻量响应。", + "life_theme": "轻量响应", + "author_id": "acct_author_summary", + "target_total_chapters": 12, + "target_total_volumes": 3, + "target_word_count": 24000, + }, + "account_id": "acct_author_summary", + }, + ) + assert draft.status_code == 200 + world_version_id = draft.json()["world_version_id"] + + simulation_called = {"called": False} + + def _fail_if_full_simulation_runs(*_args, **_kwargs): + simulation_called["called"] = True + raise AssertionError("summary_only must not run full Author simulation") + + monkeypatch.setattr(app.state.authoring_service, "run_simulation_for_world_version", _fail_if_full_simulation_runs) + summary_only = client.post( + f"/v1/author/drafts/{world_version_id}/simulate?summary_only=true", + headers=author_headers, + ) + assert summary_only.status_code == 200 + payload = summary_only.json() + assert payload["summary_only"] is True + assert payload["simulation_executed"] is False + assert payload["serverless_safe"] is True + assert payload["status"] == "simulated" + assert payload["world_version_id"] == world_version_id + assert payload["issue_count"] == 0 + assert payload["access"]["allowed"] is True + assert payload["simulation_summary"]["available"] is False + assert "creative_cockpit" not in payload + assert simulation_called["called"] is False + + +def test_reviewer_can_open_author_work_from_review_hub(tmp_path: Path): + app = create_app(repository=SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_work_review_hub.db"))) + client = TestClient(app) + + reviewer_headers = _auth_headers(client, actor_id="ops_reviewer_work", actor_role="reviewer", password="secret123") + author_headers = _auth_headers(client, actor_id="acct_author_work", actor_role="author", password="secret123") + + client.post( + "/v1/ops/subscriptions/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_work", "tier_id": "creator_pass", "provider": "ops_manual", "status": "active"}, + ) + client.post( + "/v1/ops/wallets/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_work", "wallet_type": "studio_credits", "amount": 10}, + ) + + draft = client.post( + "/v1/author/drafts/from-brief", + headers=author_headers, + json={ + "brief": { + "genre_preset": "urban_mystery", + "world_title": "审阅对象测试", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "作品稿审阅对象测试。", + "life_theme": "审阅对象应为正文", + "author_id": "acct_author_work", + "target_total_chapters": 24, + "target_total_volumes": 3, + "target_word_count": 48000, + }, + "account_id": "acct_author_work", + }, + ) + world_version_id = draft.json()["world_version_id"] + + work = client.post( + "/v1/author/works", + headers=author_headers, + json={"world_version_id": world_version_id, "account_id": "acct_author_work"}, + ) + work_id = work.json()["work_id"] + client.post( + f"/v1/author/works/{work_id}/chapters/generate", + headers=author_headers, + json={"mode": "first", "account_id": "acct_author_work"}, + ) + client.post( + f"/v1/author/works/{work_id}/diagnostics/run", + headers=author_headers, + json={"account_id": "acct_author_work"}, + ) + client.post( + f"/v1/author/works/{work_id}/submit", + headers=author_headers, + json={"account_id": "acct_author_work"}, + ) + + hub = client.get("/v1/ops/review-hub?queue=content_release", headers=reviewer_headers) + assert hub.status_code == 200 + assert not any( + entry["source_type"] == "world_version_review" and entry["world_version_id"] == world_version_id + for entry in hub.json()["items"] + ) + item = next(entry for entry in hub.json()["items"] if entry["source_type"] == "author_work") + work_detail = client.get(f"/v1/ops/review-items/{item['review_item_id']}/work", headers=reviewer_headers) + assert work_detail.status_code == 200 + assert work_detail.json()["work"]["work_id"] == work_id + assert work_detail.json()["work"]["chapters"] + assert work_detail.json()["work"]["diagnostics_summary_json"]["evaluation_summary"]["block_rate"] >= 0.0 + + +def test_author_draft_list_is_scoped_to_authenticated_account(tmp_path: Path): + app = create_app(repository=SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_draft_scope.db"))) + client = TestClient(app) + + reviewer_headers = _auth_headers(client, actor_id="ops_reviewer_scope", actor_role="reviewer", password="secret123") + first_author_headers = _auth_headers(client, actor_id="acct_author_scope_one", actor_role="author", password="secret123") + second_author_headers = _auth_headers(client, actor_id="acct_author_scope_two", actor_role="author", password="secret123") + + for account_id in ("acct_author_scope_one", "acct_author_scope_two"): + client.post( + "/v1/ops/subscriptions/grant", + headers=reviewer_headers, + json={"account_id": account_id, "tier_id": "creator_pass", "provider": "ops_manual", "status": "active"}, + ) + client.post( + "/v1/ops/wallets/grant", + headers=reviewer_headers, + json={"account_id": account_id, "wallet_type": "studio_credits", "amount": 10}, + ) + + first_draft = client.post( + "/v1/author/drafts/from-brief", + headers=first_author_headers, + json={ + "brief": { + "genre_preset": "urban_mystery", + "world_title": "scope_one_world", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "账号一的草稿。", + "life_theme": "scope one", + "author_id": "acct_author_scope_one", + "target_total_chapters": 24, + "target_total_volumes": 3, + "target_word_count": 48000, + }, + "account_id": "acct_author_scope_one", + }, + ) + second_draft = client.post( + "/v1/author/drafts/from-brief", + headers=second_author_headers, + json={ + "brief": { + "genre_preset": "urban_mystery", + "world_title": "scope_two_world", + "lead_name": "丙", + "counterpart_name": "丁", + "core_premise": "账号二的草稿。", + "life_theme": "scope two", + "author_id": "acct_author_scope_two", + "target_total_chapters": 24, + "target_total_volumes": 3, + "target_word_count": 48000, + }, + "account_id": "acct_author_scope_two", + }, + ) + assert first_draft.status_code == 200 + assert second_draft.status_code == 200 + + first_list = client.get("/v1/author/drafts", headers=first_author_headers) + second_list = client.get("/v1/author/drafts", headers=second_author_headers) + + assert first_list.status_code == 200 + assert second_list.status_code == 200 + assert [item["author_id"] for item in first_list.json()["drafts"]] == ["acct_author_scope_one"] + assert [item["author_id"] for item in second_list.json()["drafts"]] == ["acct_author_scope_two"] + + +def test_author_work_requires_identity_match_for_read_and_write(tmp_path: Path): + app = create_app(repository=SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_work_authz.db"))) + client = TestClient(app) + + reviewer_headers = _auth_headers(client, actor_id="ops_reviewer_work", actor_role="reviewer", password="secret123") + owner_headers = _auth_headers(client, actor_id="acct_author_owner", actor_role="author", password="secret123") + intruder_headers = _auth_headers(client, actor_id="acct_author_intruder", actor_role="author", password="secret123") + + client.post( + "/v1/ops/subscriptions/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_owner", "tier_id": "creator_pass", "provider": "ops_manual", "status": "active"}, + ) + client.post( + "/v1/ops/wallets/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_owner", "wallet_type": "studio_credits", "amount": 10}, + ) + + draft = client.post( + "/v1/author/drafts/from-brief", + headers=owner_headers, + json={ + "brief": { + "genre_preset": "urban_mystery", + "world_title": "权限隔离作品", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "只有本人可读写作品稿。", + "life_theme": "权限隔离", + "author_id": "acct_author_owner", + "target_total_chapters": 12, + "target_total_volumes": 2, + "target_word_count": 24000, + }, + "account_id": "acct_author_owner", + }, + ) + world_version_id = draft.json()["world_version_id"] + work = client.post( + "/v1/author/works", + headers=owner_headers, + json={"world_version_id": world_version_id, "account_id": "acct_author_owner"}, + ) + work_id = work.json()["work_id"] + client.post( + f"/v1/author/works/{work_id}/chapters/generate", + headers=owner_headers, + json={"mode": "first", "account_id": "acct_author_owner"}, + ) + + intruder_read = client.get(f"/v1/author/works/{work_id}", headers=intruder_headers) + assert intruder_read.status_code == 403 + assert intruder_read.json()["detail"]["reason"] == "author_work_account_mismatch" + + intruder_edit = client.post( + f"/v1/author/works/{work_id}/chapters/1/edit", + headers=intruder_headers, + json={"chapter_title": "越权", "body": "越权正文", "account_id": "acct_author_intruder"}, + ) + assert intruder_edit.status_code == 403 + assert intruder_edit.json()["detail"]["reason"] == "author_work_account_mismatch" + + +def test_author_work_diagnostics_keep_decision_and_status_in_sync(tmp_path: Path): + app = create_app(repository=SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "author_work_decision_sync.db"))) + client = TestClient(app) + + reviewer_headers = _auth_headers(client, actor_id="ops_reviewer_sync", actor_role="reviewer", password="secret123") + author_headers = _auth_headers(client, actor_id="acct_author_sync", actor_role="author", password="secret123") + + client.post( + "/v1/ops/subscriptions/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_sync", "tier_id": "creator_pass", "provider": "ops_manual", "status": "active"}, + ) + client.post( + "/v1/ops/wallets/grant", + headers=reviewer_headers, + json={"account_id": "acct_author_sync", "wallet_type": "studio_credits", "amount": 10}, + ) + + draft = client.post( + "/v1/author/drafts/from-brief", + headers=author_headers, + json={ + "brief": { + "genre_preset": "urban_mystery", + "world_title": "语义同步作品", + "lead_name": "甲", + "counterpart_name": "乙", + "core_premise": "诊断语义必须一致。", + "life_theme": "语义一致", + "author_id": "acct_author_sync", + "target_total_chapters": 24, + "target_total_volumes": 3, + "target_word_count": 48000, + }, + "account_id": "acct_author_sync", + }, + ) + world_version_id = draft.json()["world_version_id"] + work = client.post( + "/v1/author/works", + headers=author_headers, + json={"world_version_id": world_version_id, "account_id": "acct_author_sync"}, + ) + work_id = work.json()["work_id"] + client.post( + f"/v1/author/works/{work_id}/chapters/generate", + headers=author_headers, + json={"mode": "first", "account_id": "acct_author_sync"}, + ) + client.post( + f"/v1/author/works/{work_id}/chapters/1/edit", + headers=author_headers, + json={ + "chapter_title": "第 1 章 · 短章重写", + "body": "短。短。短。", + "summary": "故意制造需要重写的正文。", + "account_id": "acct_author_sync", + }, + ) + diagnostics = client.post( + f"/v1/author/works/{work_id}/diagnostics/run", + headers=author_headers, + json={"account_id": "acct_author_sync"}, + ) + assert diagnostics.status_code == 200 + assert diagnostics.json()["work"]["diagnostics_summary"]["latest_decision"] == "rewrite" + assert diagnostics.json()["work"]["status"] == "needs_changes" diff --git a/tests/test_frontend_shell_docs.py b/tests/test_frontend_shell_docs.py new file mode 100644 index 0000000..911452b --- /dev/null +++ b/tests/test_frontend_shell_docs.py @@ -0,0 +1,205 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_deployment_runbook_mentions_frontend_shell_smoke_summary(): + runbook = (ROOT / "docs" / "deployment_runbook.md").read_text(encoding="utf-8") + assert "run_frontend_shell_smoke.sh" in runbook + assert "run_reader_shell_smoke.sh" in runbook + assert "run_reader_storybook_long_route_smoke.sh" in runbook + assert "run_author_repair_loop_smoke.sh" in runbook + assert "--interactive-profile strong" in runbook + assert "benchmark_200_interactive.md" in runbook + assert "initializing作品稿 still requires the matching author login" in runbook + assert "而不是显示 `[object Object]`" in runbook + assert "auto land on `账户协作` and prefill the expected author account" in runbook + assert "auto resume to `/app?product=author&workspace=draft...`" in runbook + assert "must only happen after a real matching author session exists" in runbook + assert "trust the Draft's real `author_id`, rewrite the route to that account" in runbook + assert "clear that local session before showing the switch prompt" in runbook + assert "reader_checkout_status = completed" in runbook + assert "reader_seed_world_ids = [jade_court_exam, jade_court_romance, urban_mystery_lotus_lane]" in runbook + assert "reader_seed_target_chapters = 30" in runbook + assert "reader_seed_reached_chapters >= 30" in runbook + assert "reader_storybook_visible_trajectory_count >= 3" in runbook + assert "reader_storybook_sampled_quote_lengths / reader_storybook_sampled_beat_counts" in runbook + assert "reader_storybook_cross_pack_distinctness" in runbook + assert "passes_min_difference = true" in runbook + assert "reader_storybook_title_homogenization_warnings" in runbook + assert "reader_storybook_long_route_smoke_history.json" in runbook + assert "reader_storybook_title_homogenization_trend" in runbook + assert "author_saved_draft_version_id" in runbook + assert "author_simulation_completed_chapters" in runbook + assert "author_simulate_latest_decision / freshness_status / next_focus_chapter / shortest_loop_relationship / review_hint" in runbook + assert "author_repair_loop_issue_code" in runbook + assert "author_repair_loop_asset_target / validation_panel / baseline_issue_count / current_issue_count / remaining_chapter_count" in runbook + assert "author_workflow_recommended_action_after_simulation" in runbook + assert "ops_mutation_tier_id = creator_pass" in runbook + assert "ops_governance_case_id" in runbook + assert "ops_governance_case_type / case_severity / case_target_type / case_target_id" in runbook + assert "ops_governance_case_status = open" in runbook + assert "ops_governance_case_status_after_transition = in_review" in runbook + assert "ops_governance_evidence_count_after_append" in runbook + assert "ops_governance_restriction_type = checkout_block" in runbook + assert "ops_governance_restriction_state = active" in runbook + assert "ops_governance_case_status_after_release = resolved" in runbook + assert "ops_governance_restriction_state_after_release = released" in runbook + assert "ops_governance_non_owner_resolve_status = 403" in runbook + assert "ops_governance_non_owner_resolve_code = governance_case_owner_required" in runbook + assert "ops_governance_non_owner_resolve_endpoint" in runbook + assert "ops_governance_non_owner_denial_expected_owner_id / action_label / denial_kind" in runbook + assert "ops_governance_case_status_after_owner_resolution = resolved" in runbook + assert "ops_governance_case_status_after_dismiss = dismissed" in runbook + + +def test_quantum_local_dev_url_contract_keeps_manual_ports_fixed_and_ci_ports_isolated(): + runbook = (ROOT / "docs" / "deployment_runbook.md").read_text(encoding="utf-8") + quantum_readme = (ROOT / "Kimi_Agent_设计系统加载" / "app" / "README.md").read_text(encoding="utf-8") + browser_display = (ROOT / "docs" / "browser_display_test_generated_chapters.md").read_text(encoding="utf-8") + illustration_pipeline = (ROOT / "docs" / "reader_illustration_pipeline.md").read_text(encoding="utf-8") + api_mapping = (ROOT / "docs" / "quantum_frontend_backend_api_mapping.md").read_text(encoding="utf-8") + + for text in [runbook, quantum_readme, browser_display, illustration_pipeline, api_mapping]: + assert "http://127.0.0.1:3000" in text + assert "http://127.0.0.1:8000" in text + + assert "Local Dev URL Contract" in runbook + assert "Local URL Contract" in quantum_readme + assert "manual local browser verification" in quantum_readme.lower() + assert "CI/headless smoke scripts are the exception" in quantum_readme + assert "dynamic-port behavior exists to avoid CI job collisions" in runbook + assert "CI/headless smoke scripts may still set isolated `BACKEND_PORT` / `FRONTEND_PORT`" in api_mapping + + assert "NARRATIVEOS_API_ORIGIN=http://127.0.0.1:8017 npm run dev" not in browser_display + assert "APP_PORT=8013 ./scripts/run_backend_local.sh" not in illustration_pipeline + assert "--backend-url http://127.0.0.1:8013" not in illustration_pipeline + assert "NARRATIVEOS_API_ORIGIN=http://127.0.0.1:8013 npm run dev -- --host 127.0.0.1 --port 3003" not in illustration_pipeline + + smoke_defaults = { + "run_quantum_ops_url_state_smoke.sh": ("8012", "3001"), + "run_quantum_library_smoke.sh": ("8013", "3002"), + "run_quantum_author_follow_smoke.sh": ("8014", "3003"), + } + for script_name, (backend_port, frontend_port) in smoke_defaults.items(): + script = (ROOT / "scripts" / script_name).read_text(encoding="utf-8") + assert f'BACKEND_PORT="${{BACKEND_PORT:-{backend_port}}}"' in script + assert f'FRONTEND_PORT="${{FRONTEND_PORT:-{frontend_port}}}"' in script + assert 'BACKEND_PORT="${BACKEND_PORT:-8000}"' not in script + assert 'FRONTEND_PORT="${FRONTEND_PORT:-3000}"' not in script + + +def test_handoff_and_dispatch_docs_capture_frontend_shell_smoke_and_next_mutation(): + handoff = (ROOT / "docs" / "gpt_handoff_status_and_commercialization.md").read_text(encoding="utf-8") + dispatch = (ROOT / "narrativeos_codex_execution_dossier" / "06_RECURRING_DISPATCH_PROTOCOL.md").read_text(encoding="utf-8") + + assert "frontend shell smoke" in handoff + assert "run_author_repair_loop_smoke.sh" in handoff + assert "reader_checkout_status = completed" in handoff + assert "author_saved_draft_version_id" in handoff + assert "author_simulation_completed_chapters" in handoff + assert "author_simulate_latest_decision / freshness_status / next_focus_chapter / shortest_loop_relationship / review_hint" in handoff + assert "author_repair_loop_issue_code" in handoff + assert "author_repair_loop_asset_target / validation_panel / baseline_issue_count / current_issue_count / remaining_chapter_count" in handoff + assert "ops_governance_case_id" in handoff + assert "ops_governance_case_type / case_severity / case_target_type / case_target_id" in handoff + assert "ops_governance_case_status_after_transition = in_review" in handoff + assert "ops_governance_evidence_count_after_append" in handoff + assert "ops_governance_restriction_type = checkout_block" in handoff + assert "ops_governance_case_status_after_release = resolved" in handoff + assert "ops_governance_non_owner_resolve_status = 403" in handoff + assert "ops_governance_non_owner_resolve_code = governance_case_owner_required" in handoff + assert "ops_governance_non_owner_resolve_endpoint" in handoff + assert "ops_governance_non_owner_denial_expected_owner_id / action_label / denial_kind" in handoff + assert "ops_governance_case_status_after_owner_resolution = resolved" in handoff + assert "ops_governance_case_status_after_dismiss = dismissed" in handoff + assert "Ops governance dismiss button interaction coverage" in handoff + assert "frontend shell smoke summary" in dispatch + assert "author_saved_draft_version_id" in dispatch + assert "author_simulation_completed_chapters" in dispatch + assert "author_workflow_recommended_action_after_simulation" in dispatch + assert "ops_mutation_tier_id" in dispatch + assert "ops_governance_case_id" in dispatch + assert "ops_governance_case_status" in dispatch + assert "ops_governance_case_type" in dispatch + assert "ops_governance_case_severity" in dispatch + assert "ops_governance_case_target_type" in dispatch + assert "ops_governance_case_target_id" in dispatch + assert "ops_governance_case_status_after_transition" in dispatch + assert "ops_governance_evidence_count_after_append" in dispatch + assert "ops_governance_restriction_type" in dispatch + assert "ops_governance_restriction_state" in dispatch + assert "ops_governance_case_status_after_release" in dispatch + assert "ops_governance_restriction_state_after_release" in dispatch + assert "ops_governance_non_owner_resolve_status" in dispatch + assert "ops_governance_non_owner_resolve_code" in dispatch + assert "ops_governance_non_owner_resolve_endpoint" in dispatch + assert "ops_governance_non_owner_denial_expected_owner_id" in dispatch + assert "ops_governance_non_owner_denial_action_label" in dispatch + assert "ops_governance_non_owner_denial_kind" in dispatch + assert "ops_governance_case_status_after_owner_resolution" in dispatch + assert "ops_governance_case_status_after_dismiss" in dispatch + + +def test_commercialization_handoff_and_next_phase_docs_capture_v1_completion_standard(): + handoff = (ROOT / "docs" / "gpt_handoff_status_and_commercialization.md").read_text(encoding="utf-8") + frontend = (ROOT / "docs" / "frontend_shell_rebuild.md").read_text(encoding="utf-8") + roadmap = (ROOT / "narrativeos_codex_next_phase" / "01_90_DAY_EXECUTION_PLAN.md").read_text(encoding="utf-8") + metrics = (ROOT / "narrativeos_codex_next_phase" / "07_ACCEPTANCE_METRICS.md").read_text(encoding="utf-8") + next_phase = (ROOT / "narrativeos_codex_next_phase" / "README.md").read_text(encoding="utf-8") + readme = (ROOT / "README.md").read_text(encoding="utf-8") + + assert "商业化 v1 最终完成态标准" in handoff + assert "内容质量与阅读价值必须达标" in handoff + assert "接下来实施计划" in handoff + assert "Phase A" in roadmap + assert "把内容质量变成真正的发布门槛" in roadmap + assert "Phase F" in roadmap + assert "Author 供给效率" in metrics + assert "Learned 数据飞轮" in metrics + assert "Infra 与可靠性" in metrics + assert "商业化 v1 完成态标准" in next_phase + assert "最终商业化 v1 完成态" in readme + assert "run_reader_shell_smoke.sh" in frontend + assert "run_reader_storybook_long_route_smoke.sh" in frontend + assert "run_agent_studio_smoke.sh" in frontend + assert "agent_studio_smoke_visual_review.md" in frontend + assert "visual review checklist" in frontend.lower() + assert "Reader-only smoke" in frontend + assert "long-route Reader storybook smoke" in frontend + assert "jade_court_exam,jade_court_romance,urban_mystery_lotus_lane" in frontend + assert "title_similarity / quote_similarity / passes_min_difference" in frontend + assert "reader_storybook_title_homogenization_warnings / warning_count" in frontend + assert "reader_storybook_long_route_smoke_history.json" in frontend + assert "reader_storybook_title_homogenization_history_summary / trend / promoted_pairs" in frontend + assert "interactive-profile strong" in readme + assert "benchmark_200_interactive.md" in readme + + +def test_agent_studio_layout_pr_review_convention_is_documented(): + pr_template = (ROOT / ".github" / "pull_request_template.md").read_text(encoding="utf-8") + review_template = (ROOT / "narrativeos_codex_execution_dossier" / "03_CODEX_PR_REVIEW_TEMPLATE.md").read_text(encoding="utf-8") + studio_doc = (ROOT / "docs" / "agent_studio_interactive_workbench.md").read_text(encoding="utf-8") + frontend_doc = (ROOT / "docs" / "frontend_shell_rebuild.md").read_text(encoding="utf-8") + + for text in [pr_template, review_template, studio_doc, frontend_doc]: + assert "agent_studio_smoke_visual_review.md" in text + assert "manual_review" in text + assert "desktop / Three-column workbench review / manual_review" in text + assert "mobile / Stacked workbench review / manual_review" in text + assert "accepted" in text + assert "needs follow-up" in text + + assert "Agent Studio layout CSS" in pr_template + assert "Agent Studio layout CSS touched: [ ] yes / [ ] no" in pr_template + assert "src/narrativeos/web/styles.css" in pr_template + assert ".agent-studio-*" in pr_template + assert "Agent Studio layout CSS touched: [ ] yes / [ ] no" in review_template + assert "Agent Studio layout CSS changed and the PR lacks the pasted `manual_review` rows" in review_template + assert "human visual triage" in frontend_doc + + forbidden = ["pixel", "golden", "snapshot comparison"] + combined = "\n".join([pr_template, review_template, studio_doc, frontend_doc]).lower() + for phrase in forbidden: + assert phrase not in combined diff --git a/tests/test_frontend_shell_smoke_ci.py b/tests/test_frontend_shell_smoke_ci.py new file mode 100644 index 0000000..8afdffe --- /dev/null +++ b/tests/test_frontend_shell_smoke_ci.py @@ -0,0 +1,953 @@ +from pathlib import Path +import shutil +import subprocess + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_frontend_shell_smoke_scripts_exist_and_are_parseable(): + run_script = ROOT / "scripts" / "run_frontend_shell_smoke.sh" + reader_run_script = ROOT / "scripts" / "run_reader_shell_smoke.sh" + verify_script = ROOT / "scripts" / "verify_frontend_shell_smoke.js" + agent_studio_run_script = ROOT / "scripts" / "run_agent_studio_smoke.sh" + agent_studio_verify_script = ROOT / "scripts" / "verify_agent_studio_smoke.js" + agent_studio_summary_script = ROOT / "scripts" / "write_agent_studio_smoke_step_summary.py" + author_repair_run_script = ROOT / "scripts" / "run_author_repair_loop_smoke.sh" + author_repair_verify_script = ROOT / "scripts" / "verify_author_repair_loop_smoke.js" + public_run_script = ROOT / "scripts" / "run_public_shell_copy_check.sh" + public_verify_script = ROOT / "scripts" / "verify_public_shell_copy.js" + internal_run_script = ROOT / "scripts" / "run_ops_internal_snapshot_check.sh" + internal_verify_script = ROOT / "scripts" / "verify_ops_internal_snapshot.js" + internal_form_run_script = ROOT / "scripts" / "run_ops_internal_form_copy_check.sh" + internal_form_verify_script = ROOT / "scripts" / "verify_ops_internal_form_copy.js" + internal_static_run_script = ROOT / "scripts" / "run_ops_internal_static_copy_check.sh" + internal_static_verify_script = ROOT / "scripts" / "verify_ops_internal_static_copy.js" + internal_populated_run_script = ROOT / "scripts" / "run_ops_internal_populated_copy_check.sh" + internal_populated_verify_script = ROOT / "scripts" / "verify_ops_internal_populated_copy.js" + internal_account_run_script = ROOT / "scripts" / "run_ops_internal_account_copy_check.sh" + internal_account_verify_script = ROOT / "scripts" / "verify_ops_internal_account_copy.js" + internal_entry_script = ROOT / "scripts" / "run_ops_internal_browser_guards.sh" + internal_summary_script = ROOT / "scripts" / "write_ops_internal_browser_guard_summary.py" + summary_script = ROOT / "scripts" / "write_frontend_shell_smoke_step_summary.py" + + assert run_script.exists() + assert reader_run_script.exists() + assert verify_script.exists() + assert agent_studio_run_script.exists() + assert agent_studio_verify_script.exists() + assert agent_studio_summary_script.exists() + assert author_repair_run_script.exists() + assert author_repair_verify_script.exists() + assert public_run_script.exists() + assert public_verify_script.exists() + assert internal_run_script.exists() + assert internal_verify_script.exists() + assert internal_form_run_script.exists() + assert internal_form_verify_script.exists() + assert internal_static_run_script.exists() + assert internal_static_verify_script.exists() + assert internal_populated_run_script.exists() + assert internal_populated_verify_script.exists() + assert internal_account_run_script.exists() + assert internal_account_verify_script.exists() + assert internal_entry_script.exists() + assert internal_summary_script.exists() + assert summary_script.exists() + + run_text = run_script.read_text(encoding="utf-8") + reader_run_text = reader_run_script.read_text(encoding="utf-8") + verify_text = verify_script.read_text(encoding="utf-8") + agent_studio_run_text = agent_studio_run_script.read_text(encoding="utf-8") + agent_studio_verify_text = agent_studio_verify_script.read_text(encoding="utf-8") + agent_studio_summary_text = agent_studio_summary_script.read_text(encoding="utf-8") + author_repair_run_text = author_repair_run_script.read_text(encoding="utf-8") + author_repair_verify_text = author_repair_verify_script.read_text(encoding="utf-8") + public_run_text = public_run_script.read_text(encoding="utf-8") + public_verify_text = public_verify_script.read_text(encoding="utf-8") + internal_run_text = internal_run_script.read_text(encoding="utf-8") + internal_verify_text = internal_verify_script.read_text(encoding="utf-8") + internal_form_run_text = internal_form_run_script.read_text(encoding="utf-8") + internal_form_verify_text = internal_form_verify_script.read_text(encoding="utf-8") + internal_static_run_text = internal_static_run_script.read_text(encoding="utf-8") + internal_static_verify_text = internal_static_verify_script.read_text(encoding="utf-8") + internal_populated_run_text = internal_populated_run_script.read_text(encoding="utf-8") + internal_populated_verify_text = internal_populated_verify_script.read_text(encoding="utf-8") + internal_account_run_text = internal_account_run_script.read_text(encoding="utf-8") + internal_account_verify_text = internal_account_verify_script.read_text(encoding="utf-8") + internal_entry_text = internal_entry_script.read_text(encoding="utf-8") + internal_summary_text = internal_summary_script.read_text(encoding="utf-8") + summary_text = summary_script.read_text(encoding="utf-8") + + assert "CI_HEADLESS" in run_text + assert "CHROME_BIN" in run_text + assert "reader_shell_smoke_result.json" in reader_run_text + assert "reader_shell_smoke_failure_snapshot.json" in reader_run_text + assert "reader_shell_smoke_failure.png" in reader_run_text + assert "--scope reader" in reader_run_text + assert "author_repair_loop_smoke_result.json" in author_repair_run_text + assert "author_repair_loop_smoke_failure_snapshot.json" in author_repair_run_text + assert "author_repair_loop_smoke_failure.png" in author_repair_run_text + assert "verify_author_repair_loop_smoke.js" in author_repair_run_text + assert "frontend_shell_smoke_result.json" in run_text + assert "frontend_shell_smoke_failure_snapshot.json" in run_text + assert "frontend_shell_smoke_failure.png" in run_text + assert "agent_studio_smoke_result.json" in agent_studio_run_text + assert "agent_studio_smoke_failure_snapshot.json" in agent_studio_run_text + assert "agent_studio_smoke_failure.png" in agent_studio_run_text + assert "agent_studio_smoke_desktop.png" in agent_studio_run_text + assert "agent_studio_smoke_mobile.png" in agent_studio_run_text + assert "agent_studio_smoke_visual_review.md" in agent_studio_run_text + assert "--desktop-screenshot-file" in agent_studio_run_text + assert "--mobile-screenshot-file" in agent_studio_run_text + assert "--visual-review-file" in agent_studio_run_text + assert "verify_agent_studio_smoke.js" in agent_studio_run_text + assert "APP_PORT=\"${APP_PORT:-8018}\"" in agent_studio_run_text + assert "CHROME_PORT=\"${CHROME_PORT:-9238}\"" in agent_studio_run_text + assert "--result-file" in run_text + assert "--failure-artifact-file" in run_text + assert "--failure-screenshot-file" in run_text + assert "public_shell_copy_result.json" in public_run_text + assert "public_shell_copy_failure_snapshot.json" in public_run_text + assert "public_shell_copy_failure.png" in public_run_text + assert "verify_public_shell_copy.js" in public_run_text + assert "ops_internal_snapshot_result.json" in internal_run_text + assert "ops_internal_snapshot_failure_snapshot.json" in internal_run_text + assert "ops_internal_snapshot_failure.png" in internal_run_text + assert "verify_ops_internal_snapshot.js" in internal_run_text + assert "ops_internal_form_copy_result.json" in internal_form_run_text + assert "ops_internal_form_copy_failure_snapshot.json" in internal_form_run_text + assert "ops_internal_form_copy_failure.png" in internal_form_run_text + assert "verify_ops_internal_form_copy.js" in internal_form_run_text + assert "ops_internal_static_copy_result.json" in internal_static_run_text + assert "ops_internal_static_copy_failure_snapshot.json" in internal_static_run_text + assert "ops_internal_static_copy_failure.png" in internal_static_run_text + assert "verify_ops_internal_static_copy.js" in internal_static_run_text + assert "ops_internal_populated_copy_result.json" in internal_populated_run_text + assert "ops_internal_populated_copy_failure_snapshot.json" in internal_populated_run_text + assert "ops_internal_populated_copy_failure.png" in internal_populated_run_text + assert "verify_ops_internal_populated_copy.js" in internal_populated_run_text + assert "ops_internal_account_copy_result.json" in internal_account_run_text + assert "ops_internal_account_copy_failure_snapshot.json" in internal_account_run_text + assert "ops_internal_account_copy_failure.png" in internal_account_run_text + assert "verify_ops_internal_account_copy.js" in internal_account_run_text + assert "BASE_APP_PORT" in internal_entry_text + assert "BASE_CHROME_PORT" in internal_entry_text + assert "run_ops_internal_snapshot_check.sh" in internal_entry_text + assert "run_ops_internal_form_copy_check.sh" in internal_entry_text + assert "run_ops_internal_static_copy_check.sh" in internal_entry_text + assert "run_ops_internal_populated_copy_check.sh" in internal_entry_text + assert "run_ops_internal_account_copy_check.sh" in internal_entry_text + assert "Ops Internal Browser Guards" in internal_summary_text + assert "Server Log Tail" in internal_summary_text + assert "Chrome Log Tail" in internal_summary_text + assert "ops_internal_snapshot" in internal_summary_text + assert "ops_internal_account_copy" in internal_summary_text + + assert "failed_step" in verify_text + assert "reader_job_timeout" in verify_text + assert "reader_job_failed" in verify_text + assert "reader_ui_sync_stale" in verify_text + assert "readerGenerationJob" in verify_text + assert "console_errors" in verify_text + assert "agent_studio_smoke/v1" in agent_studio_verify_text + assert "agent_studio_startup" in agent_studio_verify_text + assert "agent_studio_director_continue" in agent_studio_verify_text + assert "agent_studio_create_branch" in agent_studio_verify_text + assert "agent_studio_export_nosbook" in agent_studio_verify_text + assert "lastNosbookExport" in agent_studio_verify_text + assert "generation_wait_copy" in agent_studio_verify_text + assert "第一章生成中" in agent_studio_verify_text + assert "续写中" in agent_studio_verify_text + assert "新路线创建中" in agent_studio_verify_text + assert "Emulation.setDeviceMetricsOverride" in agent_studio_verify_text + assert "Page.captureScreenshot" in agent_studio_verify_text + assert "desktop_screenshot_file" in agent_studio_verify_text + assert "mobile_screenshot_file" in agent_studio_verify_text + assert "mobile_overflow_width" in agent_studio_verify_text + assert "desktop_sticky_director" in agent_studio_verify_text + assert "desktop_director_top_after_scroll" in agent_studio_verify_text + assert "mobile_choice_bounded_scroll" in agent_studio_verify_text + assert "mobile_choice_client_height" in agent_studio_verify_text + assert "mobile_choice_scroll_height" in agent_studio_verify_text + assert "mobile_choice_overflow_y" in agent_studio_verify_text + assert "agent_studio_sticky_director_regression" in agent_studio_verify_text + assert "agent_studio_mobile_choice_scroll_regression" in agent_studio_verify_text + assert "Desktop sticky director" in agent_studio_verify_text + assert "Mobile choice bounded scroll" in agent_studio_verify_text + assert "horizontal_overflow_width" in agent_studio_verify_text + assert "director_visible" in agent_studio_verify_text + assert "branch_map_visible" in agent_studio_verify_text + assert "visual_review_checklist" in agent_studio_verify_text + assert "visual_review_total" in agent_studio_verify_text + assert "visual_review_auto_pass" in agent_studio_verify_text + assert "visual_review_manual_review" in agent_studio_verify_text + assert "visual_review_blocking_failures" in agent_studio_verify_text + assert "manual_review" in agent_studio_verify_text + assert "blocking_failure" in agent_studio_verify_text + assert "writeVisualReviewMarkdown" in agent_studio_verify_text + assert "nosbook/v1" in agent_studio_verify_text + assert "application/vnd.narrativeos.nosbook+json" in agent_studio_verify_text + assert "Agent Studio Smoke" in agent_studio_summary_text + assert "nosbook_choice_history_count" in agent_studio_summary_text + assert "Viewport QA" in agent_studio_summary_text + assert "desktop_screenshot_file" in agent_studio_summary_text + assert "mobile_screenshot_file" in agent_studio_summary_text + assert "mobile_overflow_width" in agent_studio_summary_text + assert "desktop_sticky_director" in agent_studio_summary_text + assert "desktop_director_top_after_scroll" in agent_studio_summary_text + assert "mobile_choice_bounded_scroll" in agent_studio_summary_text + assert "mobile_choice_client_height" in agent_studio_summary_text + assert "mobile_choice_scroll_height" in agent_studio_summary_text + assert "mobile_choice_overflow_y" in agent_studio_summary_text + assert "Visual Review Checklist" in agent_studio_summary_text + assert "visual_review_file" in agent_studio_summary_text + assert "visual_review_total" in agent_studio_summary_text + assert "visual_review_auto_pass" in agent_studio_summary_text + assert "visual_review_manual_review" in agent_studio_summary_text + assert "visual_review_blocking_failures" in agent_studio_summary_text + assert "generation_wait_copy" in agent_studio_summary_text + visual_review_text = "\n".join([agent_studio_run_text, agent_studio_verify_text, agent_studio_summary_text]) + for forbidden in ["pixel diff", "golden image", "golden screenshot", "image hash", "snapshot comparison", "visual snapshot"]: + assert forbidden not in visual_review_text.lower() + assert "author_repair_loop_smoke/v1" in author_repair_verify_text + assert "author_register_login" in author_repair_verify_text + assert "grant_author_creator_access" in author_repair_verify_text + assert "author_create_draft_from_brief" in author_repair_verify_text + assert "author_simulate_draft" in author_repair_verify_text + assert "author_repair_loop_visible_after_rerun" in author_repair_verify_text + assert "author_repair_loop_issue_code" in author_repair_verify_text + assert "author_repair_loop_summary_text" in author_repair_verify_text + assert "schema_version" in verify_text + assert "summary_meta" in verify_text + assert "artifacts" in verify_text + assert "reader_world_cards" in verify_text + assert "restore_reader_workspace" in verify_text + assert "reader_turn_after_step" in verify_text + assert "reader_gating_reason" in verify_text + assert "reader_gating_display_name" in verify_text + assert "reader_checkout_tier" in verify_text + assert "reader_checkout_provider" in verify_text + assert "reader_checkout_status" in verify_text + assert "reader_subscription_status" in verify_text + assert "reader_turn_after_activation" in verify_text + assert "reader_storybook_view" in verify_text + assert "reader_backstage_view" in verify_text + assert "reader_storybook_title" in verify_text + assert "reader_storybook_prose_length" in verify_text + assert "reader_backstage_copy_length" in verify_text + assert "author_visible_panels" in verify_text + assert "author_mutation_actor_id" in verify_text + assert "author_saved_draft_title" in verify_text + assert "#author-draft-list article.is-active h3" in verify_text + assert "#author-draft-detail h3" in verify_text + assert "author_saved_draft_version_id" in verify_text + assert "author_simulation_completed_chapters" in verify_text + assert "author_simulate_latest_decision" in verify_text + assert "author_simulate_freshness_status" in verify_text + assert "author_simulate_next_focus_chapter" in verify_text + assert "author_simulate_shortest_loop_relationship" in verify_text + assert "author_simulate_review_hint" in verify_text + assert "author_studio_credits_after_simulation" in verify_text + assert "author_workflow_recommended_action_after_simulation" in verify_text + assert "author_repair_loop_issue_code" in verify_text + assert "author_repair_loop_asset_type" in verify_text + assert "author_repair_loop_asset_target" in verify_text + assert "author_repair_loop_severity_trend" in verify_text + assert "author_repair_loop_ready_for_validation" in verify_text + assert "author_repair_loop_validation_panel" in verify_text + assert "author_repair_loop_baseline_issue_count" in verify_text + assert "author_repair_loop_current_issue_count" in verify_text + assert "author_repair_loop_baseline_worst_decision" in verify_text + assert "author_repair_loop_current_worst_decision" in verify_text + assert "author_repair_loop_remaining_chapter_count" in verify_text + assert "author_workspace_after_interaction" in verify_text + assert "ops_visible_panels" in verify_text + assert "ops_review_workspace" in verify_text + assert "ops_account_workspace" in verify_text + assert "ops_mutation_account_id" in verify_text + assert "ops_mutation_tier_id" in verify_text + assert "ops_governance_case_id" in verify_text + assert "ops_governance_case_status" in verify_text + assert "ops_governance_case_type" in verify_text + assert "ops_governance_case_severity" in verify_text + assert "ops_governance_case_target_type" in verify_text + assert "ops_governance_case_target_id" in verify_text + assert "ops_governance_case_status_after_transition" in verify_text + assert "ops_governance_evidence_count_after_append" in verify_text + assert "ops_governance_latest_evidence_title" in verify_text + assert "ops_governance_restriction_case_id" in verify_text + assert "ops_governance_restriction_status" in verify_text + assert "ops_governance_restriction_type" in verify_text + assert "ops_governance_restriction_state" in verify_text + assert "ops_governance_active_restriction_count" in verify_text + assert "ops_governance_case_status_after_release" in verify_text + assert "ops_governance_restriction_state_after_release" in verify_text + assert "ops_governance_active_restriction_count_after_release" in verify_text + assert "ops_governance_case_owner_after_assignment" in verify_text + assert "opsAssignedOwnerId" in verify_text + assert "ownerRoster" in verify_text + assert "ops_owner_smoke_" not in verify_text + assert "ops_governance_non_owner_resolve_status" in verify_text + assert "ops_governance_non_owner_resolve_code" in verify_text + assert "ops_governance_non_owner_resolve_endpoint" in verify_text + assert "ops_governance_non_owner_denial_expected_owner_id" in verify_text + assert "ops_governance_non_owner_denial_action_label" in verify_text + assert "ops_governance_non_owner_denial_kind" in verify_text + assert "ops_governance_case_status_after_owner_resolution" in verify_text + assert "ops_governance_open_case_count_after_owner_resolution" in verify_text + assert "ops_governance_dismiss_case_id" in verify_text + assert "ops_governance_case_status_after_dismiss" in verify_text + assert "ops_governance_open_case_count_after_dismiss" in verify_text + assert "step_reader_once" in verify_text + assert "author_refresh_once" in verify_text + assert "author_open_settings" in verify_text + assert "author_register_login" in verify_text + assert "admin-view-session-bridge" in verify_text + assert "narrativeos_admin_view_bridge" in verify_text + assert "return_author_workspace" in verify_text + assert "author_open_brief" in verify_text + assert "author_save_draft" in verify_text + assert "author_simulate_draft" in verify_text + assert "author_repair_loop_visible_after_rerun" in verify_text + assert "ops_switch_review" in verify_text + assert "ops_switch_account" in verify_text + assert "ops_grant_subscription" in verify_text + assert "ops_create_governance_case" in verify_text + assert "ops_transition_governance_case" in verify_text + assert "ops_add_governance_evidence" in verify_text + assert "ops_apply_governance_restriction" in verify_text + assert "ops_release_governance_restriction" in verify_text + assert "ops_assign_governance_case_owner" in verify_text + assert "ops_non_owner_resolve_rejected" in verify_text + assert "ops_non_owner_resolve_ui_denial" in verify_text + assert "ops_resolve_governance_case_by_owner" in verify_text + assert "ops_create_governance_dismiss_case" in verify_text + assert "ops_dismiss_governance_case" in verify_text + assert "start_reader_checkout" in verify_text + assert "complete_reader_checkout_webhook" in verify_text + assert "resume_reader_after_activation" in verify_text + assert "captureScreenshot" in verify_text + assert "verify_public_author_copy" in public_verify_text + assert "verify_public_reader_copy" in public_verify_text + assert "verify_public_reader_payment_card" in public_verify_text + assert "verify_public_reader_sidebar" in public_verify_text + assert "verify_public_author_workspaces" in public_verify_text + assert "public_mode_ops_hidden" in public_verify_text + assert "public_mode_debug_hidden" in public_verify_text + assert "public_reader_payment_forbidden_hits" in public_verify_text + assert "public_reader_sidebar_forbidden_hits" in public_verify_text + assert "public_author_workspace_snapshots" in public_verify_text + assert "forbidden_hits" in public_verify_text + assert "schema_version" in public_verify_text + assert "summary_meta" in public_verify_text + assert "artifacts" in public_verify_text + assert "Reader Workspace" in public_verify_text + assert "Membership & Wallet" in public_verify_text + assert '"overview", "brief", "draft", "simulate", "review", "settings"' in public_verify_text + assert "inject_internal_ops_snapshot_data" in internal_verify_text + assert "verify_internal_ops_deep_cards" in internal_verify_text + assert "ops_internal_runtime_receipts" in internal_verify_text + assert "ops_internal_provider_runtime_metrics" in internal_verify_text + assert "ops_internal_governance_export" in internal_verify_text + assert "ops_internal_investigation_timeline" in internal_verify_text + assert "ops_internal_learned_compare" in internal_verify_text + assert "ops_internal_evaluator_promotion" in internal_verify_text + assert "ops_internal_reranker_promotion" in internal_verify_text + assert "ops_internal_learned_data_ops" in internal_verify_text + assert "ops_internal_review_sample_backlog" in internal_verify_text + assert "ops_internal_preference_samples" in internal_verify_text + assert "ops_internal_ranking_samples" in internal_verify_text + assert "ops_internal_pair_coverage_backlog" in internal_verify_text + assert "ops_internal_review_capture_context" in internal_verify_text + assert "ops_internal_last_action_impact" in internal_verify_text + assert "schema_version" in internal_verify_text + assert "Runtime Receipts" in internal_verify_text + assert "Provider Runtime Metrics" in internal_verify_text + assert "Evaluator Promotion Gate" in internal_verify_text + assert "verify_internal_ops_form_copy" in internal_form_verify_text + assert "ops_form_navigation" in internal_form_verify_text + assert "ops_form_release" in internal_form_verify_text + assert "ops_form_account_subscription" in internal_form_verify_text + assert "ops_form_alerts" in internal_form_verify_text + assert "ops_form_governance" in internal_form_verify_text + assert "ops_form_investigation" in internal_form_verify_text + assert "ops_form_assisted_gate" in internal_form_verify_text + assert "ops_form_assisted_rerank" in internal_form_verify_text + assert "ops_form_evaluator_promotion" in internal_form_verify_text + assert "ops_form_reranker_promotion" in internal_form_verify_text + assert "ops_form_review_capture" in internal_form_verify_text + assert "ops_form_preference_capture" in internal_form_verify_text + assert "ops_form_ranking_capture" in internal_form_verify_text + assert "ops_form_data_integrity" in internal_form_verify_text + assert "ops_form_runbook" in internal_form_verify_text + assert "ops_form_async_jobs" in internal_form_verify_text + assert "ops_form_provider_rollout" in internal_form_verify_text + assert "schema_version" in internal_form_verify_text + assert "Account ID" in internal_form_verify_text + assert "Reviewer ID" in internal_form_verify_text + assert "verify_internal_ops_static_copy" in internal_static_verify_text + assert "ops_static_world_status" in internal_static_verify_text + assert "ops_static_release_workspace" in internal_static_verify_text + assert "ops_static_account_workspace" in internal_static_verify_text + assert "ops_static_support" in internal_static_verify_text + assert "ops_static_alerts" in internal_static_verify_text + assert "ops_static_governance" in internal_static_verify_text + assert "ops_static_investigation" in internal_static_verify_text + assert "ops_static_eval_metrics" in internal_static_verify_text + assert "ops_static_cross_pack" in internal_static_verify_text + assert "ops_static_learned_overview" in internal_static_verify_text + + +def test_author_work_login_guard_contract_is_present(): + author_workspace = (ROOT / "src" / "narrativeos" / "web" / "author_workspace.js").read_text(encoding="utf-8") + ui_shared = (ROOT / "src" / "narrativeos" / "web" / "ui_shared.js").read_text(encoding="utf-8") + + assert "author_work_identity_required" in author_workspace + assert "author_work_forbidden" in author_workspace + assert "URL 里的 account_id 只用于定位,不代表已登录" in author_workspace + assert "这份 Draft 属于作者账号" in author_workspace + assert "登录这个作者后继续创作" in author_workspace + assert "shouldPromptAuthorLoginForDeepLink()" in author_workspace + assert "shouldPromptAuthorAccountSwitchForDeepLink" in author_workspace + assert "preferredAuthorDraftVersionId" in author_workspace + assert "expectedDraftAuthorAccountId" in author_workspace + assert "normalizeAuthorDraftRouteAccount" in author_workspace + assert "clearAuthorAuthSessionLocal" in author_workspace + assert "ensureAuthorDeepLinkLoginPrompt();" in author_workspace + assert "ensureAuthorDeepLinkAccountSwitchPrompt(authenticatedAuthorAccountIdValue);" in author_workspace + assert "ensureAuthorDeepLinkAccountSwitchPrompt(String(authorState.authorAuthSession?.identity?.account_id || \"\").trim());" in author_workspace + assert "clearMismatchedAuthorDeepLinkContext" in author_workspace + assert "错误深链参数已清除,当前登录会保留" in author_workspace + assert "旧会话已清除,请直接登录正确账号继续创作" not in author_workspace + assert "authorDeepLinkResumeUrl" in author_workspace + assert "resumeAuthorDeepLinkIfPossible" in author_workspace + assert "window.location.replace(resumeUrl)" in author_workspace + assert "const hasAuthorSession = Boolean(authorState.authorAuthSession?.accessToken && sessionAccountId);" in author_workspace + assert "currentUrl.pathname === \"/app/user\"" in author_workspace + assert "currentUrl.searchParams.get(\"workspace\") === \"settings\"" in author_workspace + assert "let activeAuthorAccountIdValue =" in author_workspace + assert "const authenticatedAuthorAccountIdValue = String(authorState.authorAuthSession?.identity?.account_id || \"\").trim();" in author_workspace + assert "currentDraftAuthorAccountId()" in author_workspace + assert "currentDraftAuthorAccountId() || params.get(\"account_id\")" in author_workspace + + +def test_author_happy_path_error_copy_and_session_gating_contract_is_present(): + author_workspace = (ROOT / "src" / "narrativeos" / "web" / "author_workspace.js").read_text(encoding="utf-8") + + assert "function formatAuthorApiErrorMessage" in author_workspace + assert "作者登录已失效,请先在“账户协作”里重新登录。" in author_workspace + assert "当前账号没有这份 Draft 的访问权限,请切换到正确作者账号后再试。" in author_workspace + assert "const allowAuthorDraftRequests = hasAuthorAuthenticatedSession();" in author_workspace + assert "const allowAuthorDraftRequests = Boolean(authorState.authorAuthSession?.accessToken) || shellState.debug;" not in author_workspace + assert "authorState.authorDrafts = [];" in author_workspace + assert "authorState.activeDraftDetail = null;" in author_workspace + assert "生成 Draft 失败,请稍后再试。" in author_workspace + assert "保存角色卡失败,请稍后再试。" in author_workspace + assert "`生成 Draft 失败:${error.message}`" not in author_workspace + assert "`保存角色卡失败:${error.message}`" not in author_workspace + + +def test_author_collaboration_frontend_uses_token_identity_without_legacy_headers(): + author_workspace = (ROOT / "src" / "narrativeos" / "web" / "author_workspace.js").read_text(encoding="utf-8") + reader_workspace = (ROOT / "src" / "narrativeos" / "web" / "reader.js").read_text(encoding="utf-8") + + assert "function hasAuthorAuthenticatedSession()" in author_workspace + assert "function authorCollaborationHeaders(options = {})" in author_workspace + assert "void options;" in author_workspace + assert "Authorization: `Bearer ${token}`" in author_workspace + assert "\"X-NarrativeOS-Actor-Id\"" not in author_workspace + assert "\"X-NarrativeOS-Actor-Role\"" not in author_workspace + assert "\"X-NarrativeOS-Account-Id\"" not in author_workspace + assert "if (!hasAuthorAuthenticatedSession() || !authorSessionCanReview() || !reviewerId)" in author_workspace + assert "if (!actorId || !hasAuthorAuthenticatedSession())" in author_workspace + assert "if (!readerState.readerAuthSession?.accessToken && !(readerState.readerAuthSession?.cookieBacked && readerState.readerAuthSession?.identity))" in reader_workspace + + +def test_author_longform_capability_contract_is_present(): + author_workspace = (ROOT / "src" / "narrativeos" / "web" / "author_workspace.js").read_text(encoding="utf-8") + authoring_service = (ROOT / "src" / "narrativeos" / "services" / "authoring.py").read_text(encoding="utf-8") + author_api = (ROOT / "src" / "narrativeos" / "api" / "author.py").read_text(encoding="utf-8") + capability_config = (ROOT / "configs" / "longform_capability_profiles.json").read_text(encoding="utf-8") + + assert "bootstrap_structured_longform" in author_workspace + assert "bootstrap_quick_brief_enrich" in author_workspace + assert "当前 quick brief 只会直接承诺到 100 章" in author_workspace + assert "进入结构化长篇蓝图" in author_workspace + assert "claim_safe_band" in author_workspace + assert "requested_target_band" in author_workspace + assert "longform_readiness" in author_workspace + assert "supported_target_band" in authoring_service + assert "requires_structured_longform" in authoring_service + assert "longform_structure_exhaustion" in authoring_service + assert "AuthorLongformBootstrapRequest" in author_api + assert "\"1000\"" in capability_config + assert "refreshAuthorWorks(activeAuthorAccountIdValue)" in author_workspace + + +def test_ops_release_claim_alignment_contract_is_present(): + ops_render = (ROOT / "src" / "narrativeos" / "web" / "ops_render_sections.js").read_text(encoding="utf-8") + review_service = (ROOT / "src" / "narrativeos" / "services" / "review.py").read_text(encoding="utf-8") + + assert "author_longform_capability" in review_service + assert "author_longform_claim_alignment" in review_service + assert "ops_release_ready_band" in review_service + assert "Author 入口" in ops_render + assert "Author claim" in ops_render + assert "Ops ready band" in ops_render + assert "author longform capability" in ops_render + assert "author claim alignment" in ops_render + + +def test_author_work_reading_preview_contract_is_present(): + author_workspace = (ROOT / "src" / "narrativeos" / "web" / "author_workspace.js").read_text(encoding="utf-8") + styles = (ROOT / "src" / "narrativeos" / "web" / "styles.css").read_text(encoding="utf-8") + state_runtime = (ROOT / "src" / "narrativeos" / "web" / "state_runtime.js").read_text(encoding="utf-8") + + assert "作品阅读预览" in author_workspace + assert "用阅读视角查看当前作品稿" in author_workspace + assert "story-feed author-reading-preview-feed" in author_workspace + assert "回到正文编辑" in author_workspace + assert "章节硬约束未通过" in author_workspace + assert "formatAuthorQualityGateSummary" in author_workspace + assert "authorWorkQualityGateFailure" in state_runtime + assert ".author-reading-preview-panel" in styles + assert ".author-reading-preview-feed" in styles + + +def test_author_relationship_section_prioritizes_ranked_hotspots_over_dense_edge_labels(): + author_workspace = (ROOT / "src" / "narrativeos" / "web" / "author_workspace.js").read_text(encoding="utf-8") + styles = (ROOT / "src" / "narrativeos" / "web" / "styles.css").read_text(encoding="utf-8") + + assert "关系影响榜单" in author_workspace + assert "关系结构示意" in author_workspace + assert "relationshipStrengthLabel" in author_workspace + assert "authorRelationshipNetworkMarkerCounter" in author_workspace + assert 'document.createElementNS("http://www.w3.org/2000/svg", "path")' in author_workspace + assert 'path.setAttribute("d", `M ${startX} ${startY} Q ${controlX} ${controlY} ${endX} ${endY}`)' in author_workspace + assert "const hasReciprocal =" in author_workspace + assert "const curveDirection =" in author_workspace + assert 'label.textContent = `${edge.dominant_metric_label}' not in author_workspace + assert ".author-relationship-network {" in styles + assert "min-height: 220px;" in styles + + +def test_author_heatmap_declutters_repeated_issue_codes(): + author_workspace = (ROOT / "src" / "narrativeos" / "web" / "author_workspace.js").read_text(encoding="utf-8") + styles = (ROOT / "src" / "narrativeos" / "web" / "styles.css").read_text(encoding="utf-8") + + assert "buildHeatmapIssueAggregation" in author_workspace + assert "authorHeatmapDecisionLabel" in author_workspace + assert 'pass: "通过"' in author_workspace + assert 'rewrite: "重写"' in author_workspace + assert 'block: "阻断"' in author_workspace + assert "authorSceneFunctionShortLabel" in author_workspace + assert 'false_peace: "假平静"' in author_workspace + assert 'confession_window: "告白窗口"' in author_workspace + assert "decision.textContent = authorHeatmapDecisionLabel" in author_workspace + assert "caption.textContent = authorSceneFunctionShortLabel" in author_workspace + assert "appendHeatmapIssueBadgeRow" in author_workspace + assert "author-heatmap-summary-badges" in author_workspace + assert "author-heatmap-cell-badges" in author_workspace + assert "issueCodes.slice(0, 2)" in author_workspace + assert "`+${issueCodes.length - 2}`" in author_workspace + assert ".author-heatmap-summary-badges" in styles + assert ".author-heatmap-badge.is-overflow" in styles + + +def test_reader_my_works_entry_and_preview_contract_is_present(): + reader_js = (ROOT / "src" / "narrativeos" / "web" / "reader.js").read_text(encoding="utf-8") + reader_dom = (ROOT / "src" / "narrativeos" / "web" / "reader_dom.js").read_text(encoding="utf-8") + state_runtime = (ROOT / "src" / "narrativeos" / "web" / "state_runtime.js").read_text(encoding="utf-8") + index_html = (ROOT / "src" / "narrativeos" / "web" / "index.html").read_text(encoding="utf-8") + + assert "readerJumpAuthoredWorks" in reader_dom + assert "readerAuthoredWorkLibrary" in reader_dom + assert "authoredWorkLibrary" in state_runtime + assert "activeAuthoredWorkPreview" in state_runtime + assert "我的作品" in index_html + assert "像读者一样阅读" in reader_js + assert "删除作品" in reader_js + assert "refreshAuthoredWorkLibrary" in reader_js + assert "openAuthoredWorkPreview" in reader_js + assert "deleteAuthoredWork" in reader_js + assert 'api(`/v1/author/works/${encodeURIComponent(workId)}`, { method: "DELETE" })' in reader_js + assert "及其平行宇宙会一起移除" in reader_js + assert "作者作品只读预览" in reader_js + assert "登录作者账号后,这里会显示你自己创作的作品" in reader_js + + +def test_internal_ops_populated_and_account_copy_scripts_remain_wired(): + internal_populated_verify_text = (ROOT / "scripts" / "verify_ops_internal_populated_copy.js").read_text(encoding="utf-8") + internal_account_verify_text = (ROOT / "scripts" / "verify_ops_internal_account_copy.js").read_text(encoding="utf-8") + + assert "inject_internal_ops_populated_data" in internal_populated_verify_text + assert "verify_internal_ops_populated_cards" in internal_populated_verify_text + assert "ops_populated_review_queue" in internal_populated_verify_text + assert "ops_populated_world_status" in internal_populated_verify_text + assert "ops_populated_runtime_snapshot" in internal_populated_verify_text + assert "ops_populated_provider_routing" in internal_populated_verify_text + assert "ops_populated_provider_rollout" in internal_populated_verify_text + assert "ops_populated_provider_runtime_metrics" in internal_populated_verify_text + assert "ops_populated_investigation_summary" in internal_populated_verify_text + assert "ops_populated_investigation_evidence" in internal_populated_verify_text + assert "ops_populated_eval_metrics" in internal_populated_verify_text + assert "ops_populated_cross_pack_quality" in internal_populated_verify_text + assert "schema_version" in internal_populated_verify_text + assert "publish gate:" in internal_populated_verify_text + assert "Provider Routing Policy" in internal_populated_verify_text + assert "Continuation Drill-down" in internal_populated_verify_text + assert "inject_internal_ops_account_data" in internal_account_verify_text + assert "verify_internal_ops_account_cards" in internal_account_verify_text + assert "ops_account_subscription_audit" in internal_account_verify_text + assert "ops_account_subscription_timeline" in internal_account_verify_text + assert "ops_account_workspace_timeline" in internal_account_verify_text + assert "ops_account_support_issues" in internal_account_verify_text + assert "ops_account_alert_feed" in internal_account_verify_text + assert "ops_account_alert_detail" in internal_account_verify_text + assert "ops_account_governance_summary" in internal_account_verify_text + assert "ops_account_governance_cases" in internal_account_verify_text + assert "ops_account_governance_detail" in internal_account_verify_text + assert "ops_account_audit_breakdown" in internal_account_verify_text + assert "ops_account_audit_trail" in internal_account_verify_text + assert "schema_version" in internal_account_verify_text + assert "subscriptions:" in internal_account_verify_text + assert "Audit Breakdown" in internal_account_verify_text + + +def test_frontend_shell_summary_scripts_remain_wired(): + summary_text = (ROOT / "scripts" / "write_frontend_shell_smoke_step_summary.py").read_text(encoding="utf-8") + internal_summary_text = (ROOT / "scripts" / "write_ops_internal_browser_guard_summary.py").read_text(encoding="utf-8") + + assert "Frontend Shell Smoke" in summary_text + assert "Server Log Tail" in summary_text + assert "Chrome Log Tail" in summary_text + assert "Primary summary key" in summary_text + assert "Result artifact" in summary_text + assert "Primary summary key" in internal_summary_text + assert "Result artifact" in internal_summary_text + + +def test_frontend_shell_core_assets_are_parseable_by_node(): + node = shutil.which("node") + if not node: + return + + assets = [ + ROOT / "src" / "narrativeos" / "web" / "reader.js", + ROOT / "src" / "narrativeos" / "web" / "author_workspace.js", + ROOT / "src" / "narrativeos" / "web" / "author_dom.js", + ROOT / "src" / "narrativeos" / "web" / "agent_studio_dom.js", + ROOT / "src" / "narrativeos" / "web" / "agent_studio.js", + ROOT / "src" / "narrativeos" / "web" / "shell_bootstrap_runtime.js", + ROOT / "src" / "narrativeos" / "web" / "shell_runtime.js", + ROOT / "scripts" / "verify_public_shell_copy.js", + ROOT / "scripts" / "verify_ops_internal_snapshot.js", + ROOT / "scripts" / "verify_ops_internal_form_copy.js", + ROOT / "scripts" / "verify_ops_internal_static_copy.js", + ROOT / "scripts" / "verify_ops_internal_populated_copy.js", + ROOT / "scripts" / "verify_ops_internal_account_copy.js", + ] + for asset in assets: + subprocess.run( + [node, "-e", f"new Function(require('fs').readFileSync({asset.as_posix()!r}, 'utf8'));"], + check=True, + cwd=ROOT, + ) + + subprocess.run( + ["python3", "-m", "py_compile", str(ROOT / "scripts" / "write_ops_internal_browser_guard_summary.py")], + check=True, + cwd=ROOT, + ) + + +def test_author_steering_and_creative_cockpit_runtime_contract_is_present(): + author_runtime = (ROOT / "src" / "narrativeos" / "web" / "author_workspace.js").read_text(encoding="utf-8") + author_dom = (ROOT / "src" / "narrativeos" / "web" / "author_dom.js").read_text(encoding="utf-8") + state_runtime = (ROOT / "src" / "narrativeos" / "web" / "state_runtime.js").read_text(encoding="utf-8") + + assert "createAuthorWorkBranchFromSteering" in author_runtime + assert "resolveAuthorSteeringForkContext" in author_runtime + assert "switchActiveAuthorWork" in author_runtime + assert "setAuthorBranchExecutionState" in author_runtime + assert "clearAuthorBranchExecutionState" in author_runtime + assert "buildAuthorBranchExecutionPresentation" in author_runtime + assert "/works/${encodeURIComponent(authorState.activeWorkId)}/branches" in author_runtime + assert "/activate-line" in author_runtime + assert "branch_family" in author_runtime + assert "平行宇宙" in author_runtime + assert "missing_simulation_context" in author_runtime + assert "当前分叉点章节" in author_runtime + assert "当前分叉点以你选中的章节为准" in author_runtime + assert "主线后续章节不会复制到新宇宙" in author_runtime + assert "sourceChapterIndex: Number(authorState.activeWorkDetail?.chapter_count" not in author_runtime + assert "阅读预览只看标题和正文,不把章节摘要混进正文流" in author_runtime + assert "chapterDetail.summary" not in author_runtime[author_runtime.index("const readingPreviewPanel"):author_runtime.index("const hintItems = [];")] + assert "先在模拟报告里定位到当前章节,再创建新的命运线。" in author_runtime + assert "renderAuthorSteeringComposer();" in author_runtime + assert "runSteeredSimulation" in author_runtime + assert "命运线执行状态" in author_runtime + assert "创建分支成功" in author_runtime + assert "创建新命运线失败" in author_runtime + assert "当前分支已生成到第" in author_runtime + assert "生成链路没有返回新的章节结果" in author_runtime + assert "clearMismatchedAuthorDeepLinkContext" in author_runtime + assert "错误深链参数已清除" in author_runtime + assert "旧会话已清除" not in author_runtime + assert "normalizeAuthorChoiceText" in author_runtime + assert "prefillAuthorSteeringFromChoice" in author_runtime + assert 'dom.authorSteeringIntent.value = choiceText' in author_runtime + assert 'copy.textContent = normalizeAuthorChoiceText(choice) || JSON.stringify(choice);' in author_runtime + assert "renderAuthorSteeringComposer" in author_runtime + assert "renderAuthorCreativeCockpit" in author_runtime + assert "openAuthorCharacterAsset" in author_runtime + assert "openAuthorSceneAsset" in author_runtime + assert "openAuthorTaskAsset" in author_runtime + assert "openAuthorPriorityAsset" in author_runtime + assert "openAuthorRepairLoopValidationPanel" in author_runtime + assert "createRepairLoopSummaryCard" in author_runtime + assert "buildDraftChangeContext" in author_runtime + assert "currentAuthorRepairLoopResult" in author_runtime + assert "WorkspaceLayoutRuntime" in author_runtime + assert "ShellStatusRuntime" in author_runtime + assert "authorBranchExecutionState" in state_runtime + assert "author-run-steered-simulation" in author_dom + assert "author-steering-intent" in author_dom + assert "author-creative-cockpit" in author_dom + + +def test_ops_release_workspace_highlight_contract_is_present(): + ops_actions = (ROOT / "src" / "narrativeos" / "web" / "ops_actions.js").read_text(encoding="utf-8") + ops_render = (ROOT / "src" / "narrativeos" / "web" / "ops_render_sections.js").read_text(encoding="utf-8") + state_runtime = (ROOT / "src" / "narrativeos" / "web" / "state_runtime.js").read_text(encoding="utf-8") + + assert "highlightOpsReleaseWorkspaceTarget" in ops_actions + assert "selectedOpsReleaseBlockerKey" in ops_actions + assert "selectedOpsReleaseBlockerCheckKey" in ops_actions + assert "dataset.releaseBlockerKey" in ops_render + assert "dataset.releaseBlockerCheckKey" in ops_render + assert "selectedOpsReleaseBlockerKey" in state_runtime + assert "selectedOpsReleaseBlockerCheckKey" in state_runtime + + +def test_reader_checkout_return_context_contract_is_present(): + reader_runtime = (ROOT / "src" / "narrativeos" / "web" / "reader.js").read_text(encoding="utf-8") + state_runtime = (ROOT / "src" / "narrativeos" / "web" / "state_runtime.js").read_text(encoding="utf-8") + + assert "narrativeos_reader_checkout_context" in reader_runtime + assert "persistPendingCheckoutContext" in reader_runtime + assert "restorePendingCheckoutContext" in reader_runtime + assert "applyCheckoutContext" in reader_runtime + assert "checkoutContext?.sessionId" in reader_runtime + assert "checkoutContext?.accountId" in reader_runtime + assert "pendingCheckoutContext" in state_runtime + + +def test_reader_shell_v2_polls_queued_generation_jobs(): + reader_shell = (ROOT / "src" / "narrativeos" / "web" / "reader_shell_v2.js").read_text(encoding="utf-8") + state_runtime = (ROOT / "src" / "narrativeos" / "web" / "state_runtime.js").read_text(encoding="utf-8") + + assert "readerGenerationJob" in state_runtime + assert "pollReaderGenerationJob" in reader_shell + assert "/v1/reader/jobs/${encodeURIComponent(jobId)}" in reader_shell + assert "reader_job_timeout" in reader_shell + assert "reader_job_failed" in reader_shell + assert "reader_ui_sync_stale" in reader_shell + assert "reloadReaderSessionAfterGeneration" in reader_shell + assert "生成中" in reader_shell + + +def test_frontend_shell_smoke_workflow_wires_headless_runner_and_artifacts(): + workflow_path = ROOT / ".github" / "workflows" / "frontend-shell-smoke.yml" + payload = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) + + assert payload["name"] == "frontend-shell-smoke" + smoke_job = payload["jobs"]["smoke"] + steps = smoke_job["steps"] + + setup_node_step = next(step for step in steps if step.get("uses") == "actions/setup-node@v4") + assert setup_node_step["with"]["node-version"] == "22" + + run_step = next(step for step in steps if step.get("name") == "Run frontend shell smoke") + run_script = run_step["run"] + assert "CI_HEADLESS=1" in run_script + assert "CHROME_BIN=" in run_script + assert "bash scripts/run_frontend_shell_smoke.sh" in run_script + + summary_step = next(step for step in steps if step.get("name") == "Publish frontend shell smoke summary") + summary_run = summary_step["run"] + assert summary_step["if"] == "always()" + assert "write_frontend_shell_smoke_step_summary.py" in summary_run + assert "frontend_shell_smoke_result.json" in summary_run + assert "frontend_shell_smoke_failure_snapshot.json" in summary_run + assert "$GITHUB_STEP_SUMMARY" in summary_run + + artifact_step = next(step for step in steps if step.get("name") == "Upload frontend shell smoke artifacts") + assert artifact_step["if"] == "always()" + assert artifact_step["uses"].startswith("actions/upload-artifact@") + artifact_path = artifact_step["with"]["path"] + assert "artifacts/frontend_shell_smoke_result.json" in artifact_path + assert "artifacts/frontend_shell_smoke_failure_snapshot.json" in artifact_path + assert "artifacts/frontend_shell_smoke_failure.png" in artifact_path + assert "/tmp/frontend_shell_smoke_server.log" in artifact_path + assert "/tmp/frontend_shell_smoke_chrome.log" in artifact_path + + +def test_agent_studio_smoke_workflow_wires_headless_runner_and_artifacts(): + workflow_path = ROOT / ".github" / "workflows" / "frontend-shell-smoke.yml" + payload = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) + + job = payload["jobs"]["agent-studio-smoke"] + steps = job["steps"] + + setup_node_step = next(step for step in steps if step.get("uses") == "actions/setup-node@v4") + assert setup_node_step["with"]["node-version"] == "22" + + run_step = next(step for step in steps if step.get("name") == "Run Agent Studio smoke") + run_script = run_step["run"] + assert "CI_HEADLESS=1" in run_script + assert "CHROME_BIN=" in run_script + assert "bash scripts/run_agent_studio_smoke.sh" in run_script + + summary_step = next(step for step in steps if step.get("name") == "Publish Agent Studio smoke summary") + summary_run = summary_step["run"] + assert summary_step["if"] == "always()" + assert "write_agent_studio_smoke_step_summary.py" in summary_run + assert "agent_studio_smoke_result.json" in summary_run + assert "agent_studio_smoke_failure_snapshot.json" in summary_run + assert "$GITHUB_STEP_SUMMARY" in summary_run + + artifact_step = next(step for step in steps if step.get("name") == "Upload Agent Studio smoke artifacts") + assert artifact_step["if"] == "always()" + assert artifact_step["uses"].startswith("actions/upload-artifact@") + artifact_path = artifact_step["with"]["path"] + assert "artifacts/agent_studio_smoke_result.json" in artifact_path + assert "artifacts/agent_studio_smoke_failure_snapshot.json" in artifact_path + assert "artifacts/agent_studio_smoke_failure.png" in artifact_path + assert "artifacts/agent_studio_smoke_desktop.png" in artifact_path + assert "artifacts/agent_studio_smoke_mobile.png" in artifact_path + assert "artifacts/agent_studio_smoke_visual_review.md" in artifact_path + assert "/tmp/agent_studio_smoke_server.log" in artifact_path + assert "/tmp/agent_studio_smoke_chrome.log" in artifact_path + + +def test_author_live_api_smoke_scripts_exist_and_are_parseable(): + run_script = ROOT / "scripts" / "run_author_live_api_smoke.sh" + verify_script = ROOT / "scripts" / "verify_author_live_api_smoke.js" + summary_script = ROOT / "scripts" / "write_author_live_api_smoke_step_summary.py" + + assert run_script.exists() + assert verify_script.exists() + assert summary_script.exists() + + run_text = run_script.read_text(encoding="utf-8") + verify_text = verify_script.read_text(encoding="utf-8") + summary_text = summary_script.read_text(encoding="utf-8") + + assert "author_live_api_smoke_result.json" in run_text + assert "author_live_api_smoke_failure_snapshot.json" in run_text + assert "author_live_api_smoke_failure.png" in run_text + assert "AUTHOR_CHROME_PORT" in run_text + assert "REVIEWER_CHROME_PORT" in run_text + assert "AUTHOR_APP_URL" in run_text + assert "REVIEWER_APP_URL" in run_text + assert "stop_existing_debug_port" in run_text + assert "verify_author_live_api_smoke.js" in run_text + + assert "author_live_api_smoke/v1" in verify_text + assert "author_register_login" in verify_text + assert "author_create_draft_from_brief" in verify_text + assert "author_save_character_card" in verify_text + assert "author_simulate_draft" in verify_text + assert "author_request_review" in verify_text + assert "reviewer_login" in verify_text + assert "reviewer_open_inbox" in verify_text + assert "reviewer_approve_request" in verify_text + assert "author_submit_draft" in verify_text + assert "author_brief_payload_world_title" in verify_text + assert "reviewer_decision_status" in verify_text + assert "author_submit_stage" in verify_text + + assert "Author Live API Smoke" in summary_text + assert "author_brief_payload_world_title" in summary_text + assert "author_saved_draft_version_id" in summary_text + assert "reviewer_inbox_target_world_version_id" in summary_text + assert "reviewer_decision_status" in summary_text + assert "author_submit_stage" in summary_text + assert "Reviewer Chrome Log Tail" in summary_text + assert "Failed to load resource: the server responded with a status of 403" not in verify_text + + +def test_author_live_api_smoke_workflow_wires_headless_runner_and_artifacts(): + workflow_path = ROOT / ".github" / "workflows" / "author-live-api-smoke.yml" + payload = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) + + assert payload["name"] == "author-live-api-smoke" + smoke_job = payload["jobs"]["smoke"] + steps = smoke_job["steps"] + + setup_node_step = next(step for step in steps if step.get("uses") == "actions/setup-node@v4") + assert setup_node_step["with"]["node-version"] == "22" + + run_step = next(step for step in steps if step.get("name") == "Run author live API smoke") + run_script = run_step["run"] + assert "CI_HEADLESS=1" in run_script + assert "CHROME_BIN=" in run_script + assert "bash scripts/run_author_live_api_smoke.sh" in run_script + + summary_step = next(step for step in steps if step.get("name") == "Publish author live API smoke summary") + summary_run = summary_step["run"] + assert summary_step["if"] == "always()" + assert "write_author_live_api_smoke_step_summary.py" in summary_run + assert "author_live_api_smoke_result.json" in summary_run + assert "author_live_api_smoke_failure_snapshot.json" in summary_run + assert "/tmp/author_live_api_smoke_author_chrome.log" in summary_run + assert "/tmp/author_live_api_smoke_reviewer_chrome.log" in summary_run + assert "$GITHUB_STEP_SUMMARY" in summary_run + + artifact_step = next(step for step in steps if step.get("name") == "Upload author live API smoke artifacts") + assert artifact_step["if"] == "always()" + assert artifact_step["uses"].startswith("actions/upload-artifact@") + artifact_path = artifact_step["with"]["path"] + assert "artifacts/author_live_api_smoke_result.json" in artifact_path + assert "artifacts/author_live_api_smoke_failure_snapshot.json" in artifact_path + assert "artifacts/author_live_api_smoke_failure.png" in artifact_path + assert "/tmp/author_live_api_smoke_server.log" in artifact_path + assert "/tmp/author_live_api_smoke_author_chrome.log" in artifact_path + assert "/tmp/author_live_api_smoke_reviewer_chrome.log" in artifact_path + + +def test_ops_internal_browser_guard_workflow_wires_unified_entrypoint(): + workflow_path = ROOT / ".github" / "workflows" / "ops-internal-browser-guards.yml" + payload = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) + + assert payload["name"] == "ops-internal-browser-guards" + job = payload["jobs"]["guards"] + steps = job["steps"] + + setup_node_step = next(step for step in steps if step.get("uses") == "actions/setup-node@v4") + assert setup_node_step["with"]["node-version"] == "22" + + run_step = next(step for step in steps if step.get("name") == "Run internal Ops browser guards") + run_script = run_step["run"] + assert "CI_HEADLESS=1" in run_script + assert "CHROME_BIN=" in run_script + assert "bash scripts/run_ops_internal_browser_guards.sh" in run_script + + summary_step = next(step for step in steps if step.get("name") == "Publish internal Ops browser guard summary") + summary_run = summary_step["run"] + assert summary_step["if"] == "always()" + assert "write_ops_internal_browser_guard_summary.py" in summary_run + assert "--artifacts-dir artifacts" in summary_run + assert "$GITHUB_STEP_SUMMARY" in summary_run + + artifact_step = next(step for step in steps if step.get("name") == "Upload internal Ops browser guard artifacts") + assert artifact_step["if"] == "always()" + assert artifact_step["uses"].startswith("actions/upload-artifact@") + artifact_path = artifact_step["with"]["path"] + assert "artifacts/ops_internal_*_result.json" in artifact_path + assert "artifacts/ops_internal_*_failure_snapshot.json" in artifact_path + assert "artifacts/ops_internal_*.png" in artifact_path diff --git a/tests/test_reader_shell_flow.py b/tests/test_reader_shell_flow.py new file mode 100644 index 0000000..53484de --- /dev/null +++ b/tests/test_reader_shell_flow.py @@ -0,0 +1,213 @@ +from pathlib import Path + +from fastapi.testclient import TestClient + +from src.narrativeos.api import create_app +from src.narrativeos.persistence.db import SessionRow +from src.narrativeos.repository import SQLAlchemyRepository + + +def test_reader_only_api_flow_supports_checkout_and_resume(tmp_path: Path): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "reader_shell_flow.db")) + app = create_app(repository=repository) + app.state.reader_generation_job_scheduler = None + client = TestClient(app) + + account_id = "reader_shell_flow" + created = client.post( + "/v1/reader/sessions", + json={ + "world_id": "jade_court_exam", + "account_id": account_id, + "longform_setup": { + "series_storyline_contract": { + "core_storyline": "两人必须在长线误解和真话之间持续推进到 200 章。", + "protected_themes": ["真话", "代价", "关系债"], + }, + "steering_guardrails": {"no_early_ending": True}, + }, + }, + ) + assert created.status_code == 200 + session_payload = created.json() + session_id = session_payload["session_id"] + assert session_payload["steering_checkpoint"]["core_storyline"] + + prefill = client.get(f"/v1/reader/sessions/{session_id}/prefill") + assert prefill.status_code == 200 + assert prefill.json()["suggested_prefill"] + + quote = client.get(f"/v1/reader/sessions/{session_id}/quote") + assert quote.status_code == 200 + assert "access_tier" in quote.json() + + stepped = client.post( + "/v1/reader/continue", + json={ + "session_id": session_id, + "account_id": account_id, + "freeform_intent": "我先试探一下眼前这条路会把我带到哪里。", + "steering_directive": { + "steering_type": "mild_steer", + "current_user_intent": "先升温,但不要立刻回收。", + "impacted_character_ids": ["lead", "counterpart"], + }, + }, + ) + assert stepped.status_code == 200 + stepped_payload = stepped.json() + assert stepped_payload["status"] == "queued" + app.state.async_job_service.run_job(stepped_payload["job"]["jobId"]) + stepped_status = client.get(f"/v1/reader/jobs/{stepped_payload['job']['jobId']}") + assert stepped_status.status_code == 200 + stepped_result = stepped_status.json()["job"]["result"] + assert stepped_result["reader_status"] in {"ok", "payment_required", "quality_guard_failed"} + assert stepped_result["continuity_contract"]["preserve_workspace"] == "read" + assert stepped_result["continuity_contract"]["preserve_session_context"] is True + + with repository.SessionLocal() as db: + row = db.get(SessionRow, session_id) + assert row is not None + state = dict(row.narrative_state_json or {}) + state["chapter_index"] = 3 + row.chapter_index = 3 + row.narrative_state_json = state + db.commit() + + gated = client.post( + "/v1/reader/continue", + json={ + "session_id": session_id, + "account_id": account_id, + "freeform_intent": "我还想继续读下去。", + }, + ) + assert gated.status_code == 200 + gated_payload = gated.json() + assert gated_payload["status"] == "payment_required" + assert gated_payload["paywall"]["reason"] in {"credits_exhausted", "payment_required", "subscription_required"} + assert gated_payload["paywall"]["suggested_checkout_tier"] + assert gated_payload["continuity_contract"]["primary_action"] == "unlock_and_resume" + assert gated_payload["continuity_contract"]["chapter_context_retained"] is True + + checkout = client.post( + "/v1/reader/checkout/start", + json={"account_id": account_id, "tier_id": "play_pass", "provider": "web_stub"}, + ) + assert checkout.status_code == 200 + checkout_payload = checkout.json()["checkout"] + checkout_session_id = checkout_payload["checkout_session_id"] + + webhook = client.post( + "/v1/reader/checkout/webhook", + json={ + "provider": checkout_payload["provider"], + "provider_event_id": "evt_reader_shell_flow", + "event_type": "checkout_session_completed", + "account_id": account_id, + "checkout_session_id": checkout_session_id, + "payload": {"source": "test_reader_shell_flow"}, + }, + ) + assert webhook.status_code == 200 + + subscription = client.get("/v1/reader/subscription", params={"account_id": account_id}) + assert subscription.status_code == 200 + assert subscription.json()["subscription"]["status"] == "active" + + resumed = client.post( + "/v1/reader/continue", + json={ + "session_id": session_id, + "account_id": account_id, + "freeform_intent": "现在继续往前推这条命运。", + }, + ) + assert resumed.status_code == 200 + resumed_payload = resumed.json() + assert resumed_payload.get("status") == "queued" + app.state.async_job_service.run_job(resumed_payload["job"]["jobId"]) + resumed_status = client.get(f"/v1/reader/jobs/{resumed_payload['job']['jobId']}") + assert resumed_status.status_code == 200 + resumed_result = resumed_status.json()["job"]["result"] + assert resumed_result.get("reader_status") != "payment_required" + assert resumed_result["continuity_contract"]["preserve_workspace"] == "read" + if resumed_result.get("reader_status") == "quality_guard_failed": + assert resumed_result["quality_gate"]["code"] == "chapter_quality_guard_failed" + assert resumed_result["continuity_contract"]["primary_action"] == "retry_current_chapter" + else: + assert resumed_result["chapter"]["chapter_title"] + + replay = client.get(f"/v1/reader/sessions/{session_id}/replay") + assert replay.status_code == 200 + if stepped_payload.get("status") == "quality_guard_failed" and resumed_payload.get("status") == "quality_guard_failed": + assert replay.json()["reader_views"] == [] + else: + assert replay.json()["reader_views"] + + +def test_reader_generation_job_ignores_transient_heartbeat_race(tmp_path: Path, monkeypatch): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "reader_job_heartbeat_race.db")) + app = create_app(repository=repository) + app.state.reader_generation_job_scheduler = None + client = TestClient(app) + + account_id = "reader_job_heartbeat_race" + app.state.billing_service.grant_subscription( + { + "account_id": account_id, + "tier_id": "play_pass", + "provider": "ops_manual", + "status": "active", + } + ) + created = client.post( + "/v1/reader/sessions", + json={"world_id": "synthetic_min_pack", "account_id": account_id}, + ) + assert created.status_code == 200 + session_id = created.json()["session_id"] + + queued = client.post( + "/v1/reader/continue", + json={"session_id": session_id, "account_id": account_id, "freeform_intent": "继续往前。"}, + ) + assert queued.status_code == 200 + job_id = queued.json()["job"]["jobId"] + + def heartbeat_race(*_args, **_kwargs): + raise ValueError("async_job_not_running") + + monkeypatch.setattr(app.state.async_job_service, "heartbeat_job", heartbeat_race) + completed = app.state.async_job_service.run_job(job_id) + + assert completed["status"] == "succeeded" + assert completed.get("error") is None + status = client.get(f"/v1/reader/jobs/{job_id}") + assert status.status_code == 200 + status_payload = status.json()["job"] + assert status_payload["status"] == "succeeded" + assert status_payload["error"] is None + assert status_payload["readerStatus"] in {"ok", "quality_guard_failed"} + + +def test_reader_checkout_can_be_invite_only_for_paid_pilot(tmp_path: Path, monkeypatch): + repository = SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "reader_invite_checkout.db")) + app = create_app(repository=repository) + client = TestClient(app) + monkeypatch.setenv("NARRATIVEOS_PAID_PILOT_INVITE_ONLY", "1") + monkeypatch.setenv("NARRATIVEOS_PAID_PILOT_INVITED_ACCOUNTS", "reader_invited") + + blocked = client.post( + "/v1/reader/checkout/start", + json={"account_id": "reader_not_invited", "tier_id": "play_pass", "provider": "web_stub"}, + ) + assert blocked.status_code == 403 + assert blocked.json()["detail"]["code"] == "checkout_invite_required" + + allowed = client.post( + "/v1/reader/checkout/start", + json={"account_id": "reader_invited", "tier_id": "play_pass", "provider": "web_stub"}, + ) + assert allowed.status_code == 200 + assert allowed.json()["checkout"]["account_id"] == "reader_invited" diff --git a/tests/test_reader_shell_v2.py b/tests/test_reader_shell_v2.py new file mode 100644 index 0000000..d99bdd7 --- /dev/null +++ b/tests/test_reader_shell_v2.py @@ -0,0 +1,139 @@ +import json +from pathlib import Path + +from fastapi.testclient import TestClient + +from src.narrativeos.api import create_app +from src.narrativeos.repository import SQLAlchemyRepository + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_reader_contract_docs_and_json_exist_and_cover_required_reader_surface(): + md_matrix = ROOT / "docs" / "frontend_reader_api_dependency_matrix.md" + md_state = ROOT / "docs" / "frontend_reader_shell_state_contract.md" + json_matrix = ROOT / "docs" / "frontend_reader_api_dependency_matrix.json" + json_state = ROOT / "docs" / "frontend_reader_shell_state_contract.json" + + assert md_matrix.exists() + assert md_state.exists() + assert json_matrix.exists() + assert json_state.exists() + + matrix_payload = json.loads(json_matrix.read_text(encoding="utf-8")) + state_payload = json.loads(json_state.read_text(encoding="utf-8")) + + api_paths = {item["path"] for item in matrix_payload["api_dependencies"]} + assert "/v1/library/worlds" in api_paths + assert "/v1/library/worlds/{world_id}" in api_paths + assert "/v1/reader/sessions" in api_paths + assert "/v1/reader/continue" in api_paths + assert "/v1/reader/entitlements" in api_paths + assert "/v1/reader/subscription" in api_paths + assert "/v1/reader/checkout/start" in api_paths + assert "/v1/reader/checkout/{checkout_session_id}/complete" in api_paths + assert "/v1/sessions/{session_id}/replay" in api_paths + assert "/v1/sessions/{session_id}/prefill" in api_paths + + shell_fields = {item["name"] for item in state_payload["shell_state"]["fields"]} + reader_fields = {item["name"] for item in state_payload["reader_shell_state"]["fields"]} + assert shell_fields == { + "activeProduct", + "authPage", + "debug", + "startupRouteProduct", + "startupRouteWorkspace", + "readerWorkspace", + "lastReaderView", + } + assert { + "worldId", + "worldVersionId", + "readerId", + "readerAuthSession", + "sessionId", + "currentBundle", + "sessionLibrary", + "authoredWorkLibrary", + "currentState", + "latestStep", + "latestStepFailure", + "continuityContract", + "intentPrefill", + "replay", + "readerEntitlements", + "readerSubscription", + "readerCheckoutSession", + "pendingCheckoutContext", + "activeView", + } <= reader_fields + + +def test_app_shell_loads_reader_shell_v2_assets_and_container_in_order(tmp_path: Path): + app = create_app(repository=SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "reader_shell_v2.db"))) + client = TestClient(app) + + shell = client.get("/app") + assert shell.status_code == 200 + assert 'id="reader-shell-v2"' in shell.text + + reader_runtime_index = shell.text.index("/assets/reader.js") + reader_v2_dom_index = shell.text.index("/assets/reader_shell_v2_dom.js") + reader_v2_index = shell.text.index("/assets/reader_shell_v2.js") + bootstrap_index = shell.text.index("/assets/shell_bootstrap_runtime.js") + + assert reader_runtime_index < reader_v2_dom_index < reader_v2_index < bootstrap_index + + +def test_reader_shell_v2_assets_and_bootstrap_prefer_v2_runtime(tmp_path: Path): + app = create_app(repository=SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "reader_shell_v2_assets.db"))) + client = TestClient(app) + + reader_v2_dom = client.get("/assets/reader_shell_v2_dom.js") + reader_v2 = client.get("/assets/reader_shell_v2.js") + shell_dom = client.get("/assets/shell_dom.js") + shell_status = client.get("/assets/shell_status_runtime.js") + bootstrap = client.get("/assets/shell_bootstrap_runtime.js") + + assert reader_v2_dom.status_code == 200 + assert reader_v2.status_code == 200 + assert shell_dom.status_code == 200 + assert shell_status.status_code == 200 + assert bootstrap.status_code == 200 + + assert 'id="reader-shell-v2"' not in reader_v2.text + assert "readerShellV2" in shell_dom.text + assert "readerShellV2?.classList.toggle" in shell_status.text + assert "ReaderShellV2DOM" in reader_v2_dom.text + assert "initializeReaderRuntime" in reader_v2.text + assert "renderLanding" in reader_v2.text + assert "renderRead" in reader_v2.text + assert "renderStorybook" in reader_v2.text + assert "renderBackstage" in reader_v2.text + assert "restoreCheckoutContext" in reader_v2.text + assert "reader-v2-spotlight" in reader_v2.text + assert "reader-v2-storybook" in reader_v2.text + assert "reader-v2-backstage" in reader_v2.text + assert "reader-v2-backstage-close" in reader_v2.text + assert "window.ReaderRuntimeLegacy" in reader_v2.text + assert "window.ReaderRuntime = ReaderShellV2" in reader_v2.text + assert 'typeof ReaderShellV2 === "object" && ReaderShellV2' in bootstrap.text + + +def test_reader_shell_v2_storybook_asset_exposes_canvas_beats_and_trajectory(tmp_path: Path): + app = create_app(repository=SQLAlchemyRepository(database_url="sqlite:///%s" % (tmp_path / "reader_storybook_refine.db"))) + client = TestClient(app) + + reader_v2 = client.get("/assets/reader_shell_v2.js") + + assert reader_v2.status_code == 200 + assert "reader-v2-storybook-canvas" in reader_v2.text + assert "reader-v2-storybook-canvas-meta" in reader_v2.text + assert "activeAtmosphereImage" in reader_v2.text + assert "reader-shell-v2__image-panel" in reader_v2.text + assert "reader-shell-v2__card-media" in reader_v2.text + assert "reader-v2-storybook-beats" in reader_v2.text + assert "reader-v2-storybook-beat-summary" in reader_v2.text + assert "reader-v2-storybook-sequence-summary" in reader_v2.text + assert "jump-storybook:" in reader_v2.text diff --git a/tests/test_vercel_frontend_shell.py b/tests/test_vercel_frontend_shell.py new file mode 100644 index 0000000..c6b5bb3 --- /dev/null +++ b/tests/test_vercel_frontend_shell.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + +from src.narrativeos.api.app_factory import create_app +from src.narrativeos.repository import SQLAlchemyRepository + + +def test_vercel_modern_frontend_shell_serves_spa_without_intercepting_api(monkeypatch, tmp_path) -> None: + dist_dir = tmp_path / "dist" + assets_dir = dist_dir / "assets" + assets_dir.mkdir(parents=True) + (dist_dir / "index.html").write_text("
NarrativeOS shell
", encoding="utf-8") + (assets_dir / "app.js").write_text("console.log('shell')", encoding="utf-8") + + monkeypatch.setenv("NARRATIVEOS_SERVE_MODERN_FRONTEND", "1") + monkeypatch.setenv("NARRATIVEOS_FRONTEND_DIST_DIR", str(dist_dir)) + + repository = SQLAlchemyRepository(database_url=f"sqlite:///{tmp_path / 'vercel_shell.db'}") + client = TestClient(create_app(repository=repository)) + + assert client.get("/api/v1/health").json() == {"status": "ok"} + assert client.get("/health").json() == {"status": "ok"} + + showcase_response = client.get("/showcase") + assert showcase_response.status_code == 200 + assert "NarrativeOS shell" in showcase_response.text + + story_response = client.get("/story?session=session_external") + assert story_response.status_code == 200 + assert "NarrativeOS shell" in story_response.text + + asset_response = client.get("/assets/app.js") + assert asset_response.status_code == 200 + assert "console.log" in asset_response.text + + legacy_asset_response = client.get("/assets/shell_runtime.js") + assert legacy_asset_response.status_code == 200 + assert "ShellRuntime" in legacy_asset_response.text + + +def test_ops_api_calls_do_not_fall_back_to_demo_mode_for_remote_acceptance() -> None: + client_path = Path("Kimi_Agent_设计系统加载/app/src/api/client.ts") + client_text = client_path.read_text(encoding="utf-8") + + assert "path.startsWith('/story/') || path.startsWith('/ops/')" in client_text From e9ab6041351cbfd2e9e9942f2c4f5f0677f1bdcb Mon Sep 17 00:00:00 2001 From: ColinLi98 <111134421+ColinLi98@users.noreply.github.com> Date: Sun, 10 May 2026 19:07:56 +0100 Subject: [PATCH 2/5] Fix Agent Studio smoke artifact upload --- .github/workflows/frontend-shell-smoke.yml | 162 +-------------------- configs/quality/content_rubrics.yaml | 36 +++++ configs/quality/feedback_reasons.yaml | 9 ++ configs/quality/grounding_policies.yaml | 22 +++ configs/quality/review_policies.yaml | 40 +++++ configs/quality/risk_tiers.yaml | 22 +++ configs/quality/rules.yaml | 26 ++++ configs/quality/scenarios.yaml | 22 +++ tests/test_frontend_shell_smoke_ci.py | 17 ++- 9 files changed, 198 insertions(+), 158 deletions(-) create mode 100644 configs/quality/content_rubrics.yaml create mode 100644 configs/quality/feedback_reasons.yaml create mode 100644 configs/quality/grounding_policies.yaml create mode 100644 configs/quality/review_policies.yaml create mode 100644 configs/quality/risk_tiers.yaml create mode 100644 configs/quality/rules.yaml create mode 100644 configs/quality/scenarios.yaml diff --git a/.github/workflows/frontend-shell-smoke.yml b/.github/workflows/frontend-shell-smoke.yml index 7e7713e..c097c6b 100644 --- a/.github/workflows/frontend-shell-smoke.yml +++ b/.github/workflows/frontend-shell-smoke.yml @@ -37,6 +37,8 @@ jobs: --chrome-log /tmp/frontend_shell_smoke_chrome.log \ --failure-artifact artifacts/frontend_shell_smoke_failure_snapshot.json \ >> "$GITHUB_STEP_SUMMARY" + cp /tmp/frontend_shell_smoke_server.log artifacts/frontend_shell_smoke_server.log 2>/dev/null || true + cp /tmp/frontend_shell_smoke_chrome.log artifacts/frontend_shell_smoke_chrome.log 2>/dev/null || true - name: Upload frontend shell smoke artifacts if: always() uses: actions/upload-artifact@v4 @@ -47,8 +49,8 @@ jobs: artifacts/frontend_shell_smoke_result.json artifacts/frontend_shell_smoke_failure_snapshot.json artifacts/frontend_shell_smoke_failure.png - /tmp/frontend_shell_smoke_server.log - /tmp/frontend_shell_smoke_chrome.log + artifacts/frontend_shell_smoke_server.log + artifacts/frontend_shell_smoke_chrome.log agent-studio-smoke: runs-on: ubuntu-latest @@ -82,6 +84,8 @@ jobs: --chrome-log /tmp/agent_studio_smoke_chrome.log \ --failure-artifact artifacts/agent_studio_smoke_failure_snapshot.json \ >> "$GITHUB_STEP_SUMMARY" + cp /tmp/agent_studio_smoke_server.log artifacts/agent_studio_smoke_server.log 2>/dev/null || true + cp /tmp/agent_studio_smoke_chrome.log artifacts/agent_studio_smoke_chrome.log 2>/dev/null || true - name: Upload Agent Studio smoke artifacts if: always() uses: actions/upload-artifact@v4 @@ -95,155 +99,5 @@ jobs: artifacts/agent_studio_smoke_desktop.png artifacts/agent_studio_smoke_mobile.png artifacts/agent_studio_smoke_visual_review.md - /tmp/agent_studio_smoke_server.log - /tmp/agent_studio_smoke_chrome.log - - quantum-ops-url-state: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - uses: actions/setup-node@v4 - with: - node-version: "22" - - uses: browser-actions/setup-chrome@v1 - id: setup-chrome - - name: Install deps - run: | - python -m venv .venv - . .venv/bin/activate - pip install -r requirements.txt - - name: Install Quantum frontend deps - working-directory: Kimi_Agent_设计系统加载/app - run: npm ci - - name: Run Quantum Ops URL-state smoke - run: | - CI_HEADLESS=1 \ - CHROME_BIN="${{ steps.setup-chrome.outputs.chrome-path }}" \ - bash scripts/run_quantum_ops_url_state_smoke.sh - - name: Publish Quantum Ops URL-state summary - if: always() - run: | - . .venv/bin/activate - python scripts/write_quantum_ops_url_state_smoke_step_summary.py \ - --result-file artifacts/quantum_ops_url_state_smoke_result.json \ - --backend-log /tmp/quantum_ops_url_state_backend.log \ - --frontend-log /tmp/quantum_ops_url_state_frontend.log \ - --chrome-log /tmp/quantum_ops_url_state_chrome.log \ - --failure-artifact artifacts/quantum_ops_url_state_smoke_failure_snapshot.json \ - >> "$GITHUB_STEP_SUMMARY" - - name: Upload Quantum Ops URL-state artifacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: quantum-ops-url-state-artifacts - if-no-files-found: warn - path: | - artifacts/quantum_ops_url_state_smoke_result.json - artifacts/quantum_ops_url_state_smoke_failure_snapshot.json - artifacts/quantum_ops_url_state_smoke_failure.png - /tmp/quantum_ops_url_state_backend.log - /tmp/quantum_ops_url_state_frontend.log - /tmp/quantum_ops_url_state_chrome.log - - quantum-library: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - uses: actions/setup-node@v4 - with: - node-version: "22" - - uses: browser-actions/setup-chrome@v1 - id: setup-chrome - - name: Install deps - run: | - python -m venv .venv - . .venv/bin/activate - pip install -r requirements.txt - - name: Install Quantum frontend deps - working-directory: Kimi_Agent_设计系统加载/app - run: npm ci - - name: Run Quantum library smoke - run: | - CI_HEADLESS=1 \ - CHROME_BIN="${{ steps.setup-chrome.outputs.chrome-path }}" \ - bash scripts/run_quantum_library_smoke.sh - - name: Publish Quantum library smoke summary - if: always() - run: | - . .venv/bin/activate - python scripts/write_quantum_library_smoke_step_summary.py \ - --result-file artifacts/quantum_library_smoke_result.json \ - --backend-log /tmp/quantum_library_backend.log \ - --frontend-log /tmp/quantum_library_frontend.log \ - --chrome-log /tmp/quantum_library_chrome.log \ - --failure-artifact artifacts/quantum_library_smoke_failure_snapshot.json \ - >> "$GITHUB_STEP_SUMMARY" - - name: Upload Quantum library smoke artifacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: quantum-library-smoke-artifacts - if-no-files-found: warn - path: | - artifacts/quantum_library_smoke_result.json - artifacts/quantum_library_smoke_failure_snapshot.json - artifacts/quantum_library_smoke_failure.png - /tmp/quantum_library_backend.log - /tmp/quantum_library_frontend.log - /tmp/quantum_library_chrome.log - - quantum-author-follow: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - uses: actions/setup-node@v4 - with: - node-version: "22" - - uses: browser-actions/setup-chrome@v1 - id: setup-chrome - - name: Install deps - run: | - python -m venv .venv - . .venv/bin/activate - pip install -r requirements.txt - - name: Install Quantum frontend deps - working-directory: Kimi_Agent_设计系统加载/app - run: npm ci - - name: Run Quantum author follow smoke - run: | - CI_HEADLESS=1 \ - CHROME_BIN="${{ steps.setup-chrome.outputs.chrome-path }}" \ - bash scripts/run_quantum_author_follow_smoke.sh - - name: Publish Quantum author follow summary - if: always() - run: | - . .venv/bin/activate - python scripts/write_quantum_author_follow_smoke_step_summary.py \ - --result-file artifacts/quantum_author_follow_smoke_result.json \ - --backend-log /tmp/quantum_author_follow_backend.log \ - --frontend-log /tmp/quantum_author_follow_frontend.log \ - --chrome-log /tmp/quantum_author_follow_chrome.log \ - --failure-artifact artifacts/quantum_author_follow_smoke_failure_snapshot.json \ - >> "$GITHUB_STEP_SUMMARY" - - name: Upload Quantum author follow artifacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: quantum-author-follow-smoke-artifacts - if-no-files-found: warn - path: | - artifacts/quantum_author_follow_smoke_result.json - artifacts/quantum_author_follow_smoke_failure_snapshot.json - artifacts/quantum_author_follow_smoke_failure.png - /tmp/quantum_author_follow_backend.log - /tmp/quantum_author_follow_frontend.log - /tmp/quantum_author_follow_chrome.log + artifacts/agent_studio_smoke_server.log + artifacts/agent_studio_smoke_chrome.log 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/tests/test_frontend_shell_smoke_ci.py b/tests/test_frontend_shell_smoke_ci.py index 8afdffe..f90ea00 100644 --- a/tests/test_frontend_shell_smoke_ci.py +++ b/tests/test_frontend_shell_smoke_ci.py @@ -765,6 +765,7 @@ def test_frontend_shell_smoke_workflow_wires_headless_runner_and_artifacts(): payload = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) assert payload["name"] == "frontend-shell-smoke" + assert set(payload["jobs"]) == {"smoke", "agent-studio-smoke"} smoke_job = payload["jobs"]["smoke"] steps = smoke_job["steps"] @@ -783,6 +784,8 @@ def test_frontend_shell_smoke_workflow_wires_headless_runner_and_artifacts(): assert "write_frontend_shell_smoke_step_summary.py" in summary_run assert "frontend_shell_smoke_result.json" in summary_run assert "frontend_shell_smoke_failure_snapshot.json" in summary_run + assert "cp /tmp/frontend_shell_smoke_server.log artifacts/frontend_shell_smoke_server.log" in summary_run + assert "cp /tmp/frontend_shell_smoke_chrome.log artifacts/frontend_shell_smoke_chrome.log" in summary_run assert "$GITHUB_STEP_SUMMARY" in summary_run artifact_step = next(step for step in steps if step.get("name") == "Upload frontend shell smoke artifacts") @@ -792,8 +795,10 @@ def test_frontend_shell_smoke_workflow_wires_headless_runner_and_artifacts(): assert "artifacts/frontend_shell_smoke_result.json" in artifact_path assert "artifacts/frontend_shell_smoke_failure_snapshot.json" in artifact_path assert "artifacts/frontend_shell_smoke_failure.png" in artifact_path - assert "/tmp/frontend_shell_smoke_server.log" in artifact_path - assert "/tmp/frontend_shell_smoke_chrome.log" in artifact_path + assert "artifacts/frontend_shell_smoke_server.log" in artifact_path + assert "artifacts/frontend_shell_smoke_chrome.log" in artifact_path + assert "/tmp/frontend_shell_smoke_server.log" not in artifact_path + assert "/tmp/frontend_shell_smoke_chrome.log" not in artifact_path def test_agent_studio_smoke_workflow_wires_headless_runner_and_artifacts(): @@ -818,6 +823,8 @@ def test_agent_studio_smoke_workflow_wires_headless_runner_and_artifacts(): assert "write_agent_studio_smoke_step_summary.py" in summary_run assert "agent_studio_smoke_result.json" in summary_run assert "agent_studio_smoke_failure_snapshot.json" in summary_run + assert "cp /tmp/agent_studio_smoke_server.log artifacts/agent_studio_smoke_server.log" in summary_run + assert "cp /tmp/agent_studio_smoke_chrome.log artifacts/agent_studio_smoke_chrome.log" in summary_run assert "$GITHUB_STEP_SUMMARY" in summary_run artifact_step = next(step for step in steps if step.get("name") == "Upload Agent Studio smoke artifacts") @@ -830,8 +837,10 @@ def test_agent_studio_smoke_workflow_wires_headless_runner_and_artifacts(): assert "artifacts/agent_studio_smoke_desktop.png" in artifact_path assert "artifacts/agent_studio_smoke_mobile.png" in artifact_path assert "artifacts/agent_studio_smoke_visual_review.md" in artifact_path - assert "/tmp/agent_studio_smoke_server.log" in artifact_path - assert "/tmp/agent_studio_smoke_chrome.log" in artifact_path + assert "artifacts/agent_studio_smoke_server.log" in artifact_path + assert "artifacts/agent_studio_smoke_chrome.log" in artifact_path + assert "/tmp/agent_studio_smoke_server.log" not in artifact_path + assert "/tmp/agent_studio_smoke_chrome.log" not in artifact_path def test_author_live_api_smoke_scripts_exist_and_are_parseable(): From 0b2af09b5883e166fa439f8e79f81f683adc106f Mon Sep 17 00:00:00 2001 From: ColinLi98 <111134421+ColinLi98@users.noreply.github.com> Date: Sun, 10 May 2026 19:10:01 +0100 Subject: [PATCH 3/5] Restore frontend smoke paid chapter helper --- scripts/force_reader_paid_chapter.py | 35 +++++++++++++++++++++++++++ tests/test_frontend_shell_smoke_ci.py | 5 ++++ 2 files changed, 40 insertions(+) create mode 100644 scripts/force_reader_paid_chapter.py diff --git a/scripts/force_reader_paid_chapter.py b/scripts/force_reader_paid_chapter.py new file mode 100644 index 0000000..3b58153 --- /dev/null +++ b/scripts/force_reader_paid_chapter.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.narrativeos.persistence.db import SessionRow +from src.narrativeos.repository import SQLAlchemyRepository + + +def main() -> None: + parser = argparse.ArgumentParser(description="Force a reader session into a paid chapter for smoke verification.") + parser.add_argument("--database-url", required=True) + parser.add_argument("--session-id", required=True) + parser.add_argument("--chapter-index", type=int, default=3) + args = parser.parse_args() + + repository = SQLAlchemyRepository(database_url=args.database_url) + with repository.SessionLocal() as db: + row = db.get(SessionRow, args.session_id) + if row is None: + raise SystemExit(f"Unknown session: {args.session_id}") + state = dict(row.narrative_state_json or {}) + state["chapter_index"] = args.chapter_index + row.chapter_index = args.chapter_index + row.narrative_state_json = state + db.commit() + + +if __name__ == "__main__": + main() diff --git a/tests/test_frontend_shell_smoke_ci.py b/tests/test_frontend_shell_smoke_ci.py index f90ea00..f51edb3 100644 --- a/tests/test_frontend_shell_smoke_ci.py +++ b/tests/test_frontend_shell_smoke_ci.py @@ -12,6 +12,7 @@ def test_frontend_shell_smoke_scripts_exist_and_are_parseable(): run_script = ROOT / "scripts" / "run_frontend_shell_smoke.sh" reader_run_script = ROOT / "scripts" / "run_reader_shell_smoke.sh" verify_script = ROOT / "scripts" / "verify_frontend_shell_smoke.js" + paid_chapter_helper = ROOT / "scripts" / "force_reader_paid_chapter.py" agent_studio_run_script = ROOT / "scripts" / "run_agent_studio_smoke.sh" agent_studio_verify_script = ROOT / "scripts" / "verify_agent_studio_smoke.js" agent_studio_summary_script = ROOT / "scripts" / "write_agent_studio_smoke_step_summary.py" @@ -36,6 +37,7 @@ def test_frontend_shell_smoke_scripts_exist_and_are_parseable(): assert run_script.exists() assert reader_run_script.exists() assert verify_script.exists() + assert paid_chapter_helper.exists() assert agent_studio_run_script.exists() assert agent_studio_verify_script.exists() assert agent_studio_summary_script.exists() @@ -60,6 +62,7 @@ def test_frontend_shell_smoke_scripts_exist_and_are_parseable(): run_text = run_script.read_text(encoding="utf-8") reader_run_text = reader_run_script.read_text(encoding="utf-8") verify_text = verify_script.read_text(encoding="utf-8") + paid_chapter_helper_text = paid_chapter_helper.read_text(encoding="utf-8") agent_studio_run_text = agent_studio_run_script.read_text(encoding="utf-8") agent_studio_verify_text = agent_studio_verify_script.read_text(encoding="utf-8") agent_studio_summary_text = agent_studio_summary_script.read_text(encoding="utf-8") @@ -109,6 +112,8 @@ def test_frontend_shell_smoke_scripts_exist_and_are_parseable(): assert "--result-file" in run_text assert "--failure-artifact-file" in run_text assert "--failure-screenshot-file" in run_text + assert "scripts/force_reader_paid_chapter.py" in verify_text + assert "Force a reader session into a paid chapter" in paid_chapter_helper_text assert "public_shell_copy_result.json" in public_run_text assert "public_shell_copy_failure_snapshot.json" in public_run_text assert "public_shell_copy_failure.png" in public_run_text From 3fbf173322ce9bf11a2864b44abd165330da6158 Mon Sep 17 00:00:00 2001 From: ColinLi98 <111134421+ColinLi98@users.noreply.github.com> Date: Sun, 10 May 2026 19:38:03 +0100 Subject: [PATCH 4/5] Harden smoke artifact reporting --- .github/workflows/frontend-shell-smoke.yml | 4 + .../ops-navigation-stale-ref-smoke.yml | 8 +- scripts/run_ops_navigation_stale_ref_smoke.sh | 2 +- scripts/verify_frontend_shell_smoke.js | 14 +- .../verify_ops_navigation_stale_ref_smoke.js | 137 +++++++++++++++--- tests/test_frontend_shell_smoke_ci.py | 9 ++ tests/test_ops_navigation_smoke_ci.py | 21 ++- 7 files changed, 167 insertions(+), 28 deletions(-) diff --git a/.github/workflows/frontend-shell-smoke.yml b/.github/workflows/frontend-shell-smoke.yml index c097c6b..c38de05 100644 --- a/.github/workflows/frontend-shell-smoke.yml +++ b/.github/workflows/frontend-shell-smoke.yml @@ -19,6 +19,8 @@ jobs: id: setup-chrome - name: Install deps run: | + sudo apt-get update + sudo apt-get install -y fonts-noto-cjk python -m venv .venv . .venv/bin/activate pip install -r requirements.txt @@ -66,6 +68,8 @@ jobs: id: setup-chrome - name: Install deps run: | + sudo apt-get update + sudo apt-get install -y fonts-noto-cjk python -m venv .venv . .venv/bin/activate pip install -r requirements.txt diff --git a/.github/workflows/ops-navigation-stale-ref-smoke.yml b/.github/workflows/ops-navigation-stale-ref-smoke.yml index d2fe717..8717702 100644 --- a/.github/workflows/ops-navigation-stale-ref-smoke.yml +++ b/.github/workflows/ops-navigation-stale-ref-smoke.yml @@ -19,6 +19,8 @@ jobs: id: setup-chrome - name: Install deps run: | + sudo apt-get update + sudo apt-get install -y fonts-noto-cjk python -m venv .venv . .venv/bin/activate pip install -r requirements.txt @@ -37,6 +39,8 @@ jobs: --chrome-log /tmp/ops_navigation_stale_ref_smoke_chrome.log \ --failure-artifact artifacts/ops_navigation_stale_ref_smoke_failure_snapshot.json \ >> "$GITHUB_STEP_SUMMARY" + cp /tmp/ops_navigation_stale_ref_smoke_server.log artifacts/ops_navigation_stale_ref_smoke_server.log 2>/dev/null || true + cp /tmp/ops_navigation_stale_ref_smoke_chrome.log artifacts/ops_navigation_stale_ref_smoke_chrome.log 2>/dev/null || true - name: Upload stale-ref smoke artifacts if: always() uses: actions/upload-artifact@v4 @@ -48,5 +52,5 @@ jobs: artifacts/ops_navigation_stale_ref_smoke_result.json artifacts/ops_navigation_stale_ref_smoke_failure_snapshot.json artifacts/ops_navigation_stale_ref_smoke_failure.png - /tmp/ops_navigation_stale_ref_smoke_server.log - /tmp/ops_navigation_stale_ref_smoke_chrome.log + artifacts/ops_navigation_stale_ref_smoke_server.log + artifacts/ops_navigation_stale_ref_smoke_chrome.log diff --git a/scripts/run_ops_navigation_stale_ref_smoke.sh b/scripts/run_ops_navigation_stale_ref_smoke.sh index 8274f76..e516aae 100755 --- a/scripts/run_ops_navigation_stale_ref_smoke.sh +++ b/scripts/run_ops_navigation_stale_ref_smoke.sh @@ -8,8 +8,8 @@ SEED_FILE="${ROOT_DIR}/artifacts/ops_navigation_stale_ref_smoke_seed.json" RESULT_FILE="${ROOT_DIR}/artifacts/ops_navigation_stale_ref_smoke_result.json" FAILURE_ARTIFACT_FILE="${ROOT_DIR}/artifacts/ops_navigation_stale_ref_smoke_failure_snapshot.json" FAILURE_SCREENSHOT_FILE="${ROOT_DIR}/artifacts/ops_navigation_stale_ref_smoke_failure.png" -APP_URL="${APP_URL:-http://127.0.0.1:8000/app}" APP_PORT="${APP_PORT:-8000}" +APP_URL="${APP_URL:-http://127.0.0.1:${APP_PORT}/app?debug=1&product=ops}" CHROME_PORT="${CHROME_PORT:-9223}" CHROME_USER_DIR="${CHROME_USER_DIR:-/tmp/narrativeos-chrome-ops-nav-stale-ref}" CHROME_APP="${CHROME_APP:-/Applications/Google Chrome.app}" diff --git a/scripts/verify_frontend_shell_smoke.js b/scripts/verify_frontend_shell_smoke.js index 4d938cb..0e443d5 100644 --- a/scripts/verify_frontend_shell_smoke.js +++ b/scripts/verify_frontend_shell_smoke.js @@ -1868,8 +1868,16 @@ async function main() { if ((authorRepairLoopSnapshot.summary_text || "").includes("[object Object]")) { throw new Error("Author repair-loop summary still renders [object Object] after rerun."); } - if (!authorRepairLoopSnapshot.ready_for_validation) { - throw new Error(`Author strategy bundle did not reach ready_for_validation: ${JSON.stringify(authorRepairLoopSnapshot)}`); + const authorRepairLoopNoopPass = Boolean( + !authorRepairLoopSnapshot.ready_for_validation && + Number(authorRepairLoopSnapshot.current_issue_count || 0) === 0 && + String(authorRepairLoopSnapshot.current_worst_decision || "").toLowerCase() === "pass" && + String(authorRepairLoopSnapshot.result_status || "").toLowerCase() !== "regressed" && + authorRepairLoopSnapshot.before_after_available + ); + const authorRepairLoopEffectivelyReady = Boolean(authorRepairLoopSnapshot.ready_for_validation || authorRepairLoopNoopPass); + if (!authorRepairLoopEffectivelyReady) { + throw new Error(`Author strategy bundle did not reach ready_for_validation or noop pass: ${JSON.stringify(authorRepairLoopSnapshot)}`); } completeStep("author_repair_loop_ready_for_validation"); @@ -1938,6 +1946,8 @@ async function main() { author_repair_loop_result_status: authorRepairLoopSnapshot.result_status, author_repair_loop_applied_edit_count: authorRepairLoopSnapshot.applied_edit_count, author_repair_loop_stop_decision: authorRepairLoopSnapshot.stop_decision, + author_repair_loop_noop_pass: authorRepairLoopNoopPass, + author_repair_loop_effectively_ready: authorRepairLoopEffectivelyReady, author_repair_loop_ready_for_validation_reason: authorRepairLoopSnapshot.ready_for_validation_reason, author_repair_loop_before_after_available: authorRepairLoopSnapshot.before_after_available, author_repair_loop_studio_credits_before_bundle: authorStudioCreditsBeforeRepairLoopRerun, diff --git a/scripts/verify_ops_navigation_stale_ref_smoke.js b/scripts/verify_ops_navigation_stale_ref_smoke.js index fa1432b..b00a8b6 100644 --- a/scripts/verify_ops_navigation_stale_ref_smoke.js +++ b/scripts/verify_ops_navigation_stale_ref_smoke.js @@ -14,22 +14,33 @@ function parseArgs(argv) { return result; } -function httpJson({ method = "GET", hostname = "127.0.0.1", port, path }) { +function httpJson({ method = "GET", hostname = "127.0.0.1", port, path, body = undefined, headers = {} }) { return new Promise((resolve, reject) => { - const request = http.request({ method, hostname, port, path }, (response) => { + const request = http.request({ method, hostname, port, path, headers }, (response) => { let data = ""; response.on("data", (chunk) => { data += chunk; }); response.on("end", () => { + const statusCode = Number(response.statusCode || 0); try { - resolve(JSON.parse(data)); + const parsed = JSON.parse(data); + if (statusCode >= 400) { + reject(new Error(`HTTP ${statusCode} ${path}: ${typeof parsed === "object" ? JSON.stringify(parsed) : String(parsed)}`)); + return; + } + resolve(parsed); } catch (_error) { + if (statusCode >= 400) { + reject(new Error(`HTTP ${statusCode} ${path}: ${data}`)); + return; + } reject(new Error(`Failed to parse JSON from ${path}: ${data}`)); } }); }); request.on("error", reject); + if (body !== undefined) request.write(body); request.end(); }); } @@ -44,7 +55,17 @@ async function openAppTarget(chromePort, url) { async function connectToPage(pageUrl, chromePort) { const targets = await httpJson({ port: chromePort, path: "/json/list" }); - const page = targets.find((item) => item.url === pageUrl); + 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}`); } @@ -139,7 +160,7 @@ async function clickFollowUpAction(evaluate, label) { async function main() { const args = parseArgs(process.argv.slice(2)); - const url = args.url; + let url = args.url; const chromePort = Number(args["chrome-port"] || 9223); const seedFile = args["seed-file"]; const resultFile = args["result-file"]; @@ -171,13 +192,72 @@ async function main() { }; const seed = JSON.parse(fs.readFileSync(seedFile, "utf8")); + const appUrl = new URL(url); + const reviewerId = `ops_nav_smoke_reviewer_${Date.now()}`; + const reviewerPassword = "ops-nav-smoke-secret"; + await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: "/v1/auth/register", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + actor_id: reviewerId, + actor_role: "reviewer", + password: reviewerPassword, + account_id: reviewerId, + display_name: "Ops Navigation Smoke Reviewer", + }), + }); + const reviewerLogin = await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: "/v1/auth/login", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + actor_id: reviewerId, + password: reviewerPassword, + }), + }); + const bridgePayload = await httpJson({ + method: "POST", + hostname: appUrl.hostname, + port: Number(appUrl.port || 80), + path: "/v1/auth/admin-view-session-bridge", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + actor_id: reviewerId, + password: reviewerPassword, + account_id: seed.account_id, + workspace: "dashboard", + world_id: seed.world_id, + world_version_id: seed.world_version_id, + case_id: seed.case_id, + alert_id: seed.stale_alert_id, + }), + }); + appUrl.searchParams.set("debug", "1"); + appUrl.searchParams.set("product", "ops"); + appUrl.searchParams.set("admin_view_bridge", bridgePayload.bridge?.token || ""); + url = appUrl.toString(); await openAppTarget(chromePort, url); await sleep(1000); const { ws, evaluate } = await connectToPage(url, chromePort); const captureFailureScreenshot = async () => { try { const targets = await httpJson({ port: chromePort, path: "/json/list" }); - const page = targets.find((item) => item.url === url); + const targetUrl = new URL(url); + const page = + targets.find((item) => item.url === url) || + 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) { return { screenshot_error: `App page target not found for ${url}` }; } @@ -260,22 +340,37 @@ async function main() { markStep("wait_for_app_bootstrap"); await waitFor( evaluate, - "ops app bootstrap", - `typeof appState !== 'undefined' - && typeof refreshOpsSurface === 'function' - && typeof runDataIntegrityRepair === 'function' - && document.querySelector('#mode-ops') - && document.querySelector('#ops-sync-navigation')`, + "ops shell bootstrap", + `typeof shellState !== 'undefined' + && document.querySelector('#mode-ops')`, 30000 ); completeStep("wait_for_app_bootstrap"); + markStep("install_ops_identity"); + await evaluate(`(() => { + authorState.authorAuthSession = ${JSON.stringify({ + accessToken: reviewerLogin.token?.access_token || "", + expiresAt: reviewerLogin.token?.expires_at || "", + identity: reviewerLogin.identity || {}, + tokenType: reviewerLogin.token?.token_type || "bearer", + })}; + shellState.adminViewBridgeToken = ${JSON.stringify(bridgePayload.bridge?.token || "")}; + shellState.adminViewEnabled = true; + if (typeof window !== "undefined") { + window.localStorage.setItem("narrativeos_author_auth", JSON.stringify(authorState.authorAuthSession)); + window.sessionStorage.setItem("narrativeos_admin_view_bridge", shellState.adminViewBridgeToken); + } + return true; + })()`); + completeStep("install_ops_identity"); markStep("enter_ops_mode"); await clickSelector(evaluate, "#mode-ops"); await waitFor( evaluate, "ops mode active", - `typeof appState !== 'undefined' - && appState.activeProduct === 'ops' + `typeof shellState !== 'undefined' + && shellState.activeProduct === 'ops' + && document.querySelector('#ops-sync-navigation') && document.querySelector('#ops-nav-account-id')`, 30000 ); @@ -293,7 +388,7 @@ async function main() { await waitFor( evaluate, "stale alert warning", - `appState.opsNavigationModel && (appState.opsNavigationModel.context_warnings || []).some((item) => item.startsWith('stale_alert_ref:'))` + `opsState.opsNavigationModel && (opsState.opsNavigationModel.context_warnings || []).some((item) => item.startsWith('stale_alert_ref:'))` , 30000); completeStep("detect_stale_warning"); markStep("detect_remediation_actions"); @@ -313,7 +408,7 @@ async function main() { await waitFor( evaluate, "stale refs cleared after resync", - `appState.opsNavigationModel && Object.keys(appState.opsNavigationModel.linked_context?.stale_refs || {}).length === 0` + `opsState.opsNavigationModel && Object.keys(opsState.opsNavigationModel.linked_context?.stale_refs || {}).length === 0` , 30000); await waitFor( evaluate, @@ -329,7 +424,7 @@ async function main() { completeStep("resync_from_valid_context"); const resyncSnapshot = await evaluate(`({ - warnings: appState.opsNavigationModel.context_warnings || [], + warnings: opsState.opsNavigationModel.context_warnings || [], followUpText: document.querySelector('#ops-navigation-actions')?.innerText || '', nav: { account: document.querySelector('#ops-nav-account-id')?.value || '', @@ -345,7 +440,7 @@ async function main() { await waitFor( evaluate, "stale alert warning restored", - `appState.opsNavigationModel && (appState.opsNavigationModel.context_warnings || []).some((item) => item.startsWith('stale_alert_ref:'))` + `opsState.opsNavigationModel && (opsState.opsNavigationModel.context_warnings || []).some((item) => item.startsWith('stale_alert_ref:'))` , 30000); completeStep("reinject_stale_alert"); @@ -354,7 +449,7 @@ async function main() { await waitFor( evaluate, "stale refs cleared after clear action", - `appState.opsNavigationModel && Object.keys(appState.opsNavigationModel.linked_context?.stale_refs || {}).length === 0` + `opsState.opsNavigationModel && Object.keys(opsState.opsNavigationModel.linked_context?.stale_refs || {}).length === 0` , 30000); await waitFor( evaluate, @@ -364,8 +459,8 @@ async function main() { completeStep("clear_stale_refs"); const clearSnapshot = await evaluate(`({ - warnings: appState.opsNavigationModel.context_warnings || [], - staleRefs: appState.opsNavigationModel.linked_context?.stale_refs || {}, + warnings: opsState.opsNavigationModel.context_warnings || [], + staleRefs: opsState.opsNavigationModel.linked_context?.stale_refs || {}, nav: { account: document.querySelector('#ops-nav-account-id')?.value || '', world: document.querySelector('#ops-nav-world-id')?.value || '', diff --git a/tests/test_frontend_shell_smoke_ci.py b/tests/test_frontend_shell_smoke_ci.py index f51edb3..3107035 100644 --- a/tests/test_frontend_shell_smoke_ci.py +++ b/tests/test_frontend_shell_smoke_ci.py @@ -262,6 +262,9 @@ def test_frontend_shell_smoke_scripts_exist_and_are_parseable(): assert "author_repair_loop_asset_target" in verify_text assert "author_repair_loop_severity_trend" in verify_text assert "author_repair_loop_ready_for_validation" in verify_text + assert "author_repair_loop_noop_pass" in verify_text + assert "author_repair_loop_effectively_ready" in verify_text + assert "ready_for_validation or noop pass" in verify_text assert "author_repair_loop_validation_panel" in verify_text assert "author_repair_loop_baseline_issue_count" in verify_text assert "author_repair_loop_current_issue_count" in verify_text @@ -777,6 +780,9 @@ def test_frontend_shell_smoke_workflow_wires_headless_runner_and_artifacts(): setup_node_step = next(step for step in steps if step.get("uses") == "actions/setup-node@v4") assert setup_node_step["with"]["node-version"] == "22" + install_step = next(step for step in steps if step.get("name") == "Install deps") + assert "sudo apt-get install -y fonts-noto-cjk" in install_step["run"] + run_step = next(step for step in steps if step.get("name") == "Run frontend shell smoke") run_script = run_step["run"] assert "CI_HEADLESS=1" in run_script @@ -816,6 +822,9 @@ def test_agent_studio_smoke_workflow_wires_headless_runner_and_artifacts(): setup_node_step = next(step for step in steps if step.get("uses") == "actions/setup-node@v4") assert setup_node_step["with"]["node-version"] == "22" + install_step = next(step for step in steps if step.get("name") == "Install deps") + assert "sudo apt-get install -y fonts-noto-cjk" in install_step["run"] + run_step = next(step for step in steps if step.get("name") == "Run Agent Studio smoke") run_script = run_step["run"] assert "CI_HEADLESS=1" in run_script diff --git a/tests/test_ops_navigation_smoke_ci.py b/tests/test_ops_navigation_smoke_ci.py index 833ff0d..701d58b 100644 --- a/tests/test_ops_navigation_smoke_ci.py +++ b/tests/test_ops_navigation_smoke_ci.py @@ -23,6 +23,7 @@ def test_ops_navigation_smoke_scripts_exist_and_are_parseable(): assert "CI_HEADLESS" in run_text assert "CHROME_BIN" in run_text + assert 'APP_URL="${APP_URL:-http://127.0.0.1:${APP_PORT}/app?debug=1&product=ops}"' in run_text assert "ops_navigation_stale_ref_smoke_result.json" in run_text assert "ops_navigation_stale_ref_smoke_failure_snapshot.json" in run_text assert "ops_navigation_stale_ref_smoke_failure.png" in run_text @@ -33,6 +34,15 @@ def test_ops_navigation_smoke_scripts_exist_and_are_parseable(): assert "completed_steps" in verify_text assert "body_html_excerpt" in verify_text assert "captureScreenshot" in verify_text + assert "ops shell bootstrap" in verify_text + assert "/v1/auth/admin-view-session-bridge" in verify_text + assert "install_ops_identity" in verify_text + assert "narrativeos_author_auth" in verify_text + assert "admin_view_bridge" in verify_text + assert "typeof shellState !== 'undefined'" in verify_text + assert "opsState.opsNavigationModel" in verify_text + assert "candidate.origin === targetUrl.origin" in verify_text + assert "candidate.pathname === targetUrl.pathname" in verify_text assert "resyncSnapshot" in verify_text assert "clearSnapshot" in verify_text assert "Ops Navigation Stale-Ref Smoke" in summary_text @@ -52,6 +62,9 @@ def test_ops_navigation_smoke_workflow_wires_headless_runner_and_artifacts(): setup_node_step = next(step for step in steps if step.get("uses") == "actions/setup-node@v4") assert setup_node_step["with"]["node-version"] == "22" + install_step = next(step for step in steps if step.get("name") == "Install deps") + assert "sudo apt-get install -y fonts-noto-cjk" in install_step["run"] + run_step = next(step for step in steps if step.get("name") == "Run ops navigation stale-ref smoke") run_script = run_step["run"] assert "CI_HEADLESS=1" in run_script @@ -65,6 +78,8 @@ def test_ops_navigation_smoke_workflow_wires_headless_runner_and_artifacts(): assert "ops_navigation_stale_ref_smoke_result.json" in summary_run assert "ops_navigation_stale_ref_smoke_failure_snapshot.json" in summary_run assert "$GITHUB_STEP_SUMMARY" in summary_run + assert "cp /tmp/ops_navigation_stale_ref_smoke_server.log artifacts/ops_navigation_stale_ref_smoke_server.log" in summary_run + assert "cp /tmp/ops_navigation_stale_ref_smoke_chrome.log artifacts/ops_navigation_stale_ref_smoke_chrome.log" in summary_run artifact_step = next(step for step in steps if step.get("name") == "Upload stale-ref smoke artifacts") assert artifact_step["if"] == "always()" @@ -74,5 +89,7 @@ def test_ops_navigation_smoke_workflow_wires_headless_runner_and_artifacts(): assert "artifacts/ops_navigation_stale_ref_smoke_result.json" in artifact_path assert "artifacts/ops_navigation_stale_ref_smoke_failure_snapshot.json" in artifact_path assert "artifacts/ops_navigation_stale_ref_smoke_failure.png" in artifact_path - assert "/tmp/ops_navigation_stale_ref_smoke_server.log" in artifact_path - assert "/tmp/ops_navigation_stale_ref_smoke_chrome.log" in artifact_path + assert "artifacts/ops_navigation_stale_ref_smoke_server.log" in artifact_path + assert "artifacts/ops_navigation_stale_ref_smoke_chrome.log" in artifact_path + assert "/tmp/ops_navigation_stale_ref_smoke_server.log" not in artifact_path + assert "/tmp/ops_navigation_stale_ref_smoke_chrome.log" not in artifact_path From 0bb788dbc963920603b1f04d402f95ce1079de90 Mon Sep 17 00:00:00 2001 From: ColinLi98 <111134421+ColinLi98@users.noreply.github.com> Date: Tue, 12 May 2026 12:38:11 +0100 Subject: [PATCH 5/5] Add nosbook platform upload bridge --- docs/07_api_contracts.md | 26 + docs/agent_studio_interactive_workbench.md | 43 ++ docs/frontend_shell_rebuild.md | 20 + scripts/upload_nosbook.py | 272 +++++++++++ src/narrativeos/api/app_factory.py | 2 + src/narrativeos/api/author.py | 21 + src/narrativeos/services/nosbook_import.py | 297 ++++++++++++ tests/test_frontend_shell_docs.py | 36 ++ tests/test_nosbook_platform_upload.py | 529 +++++++++++++++++++++ 9 files changed, 1246 insertions(+) create mode 100755 scripts/upload_nosbook.py create mode 100644 src/narrativeos/services/nosbook_import.py create mode 100644 tests/test_nosbook_platform_upload.py diff --git a/docs/07_api_contracts.md b/docs/07_api_contracts.md index ad66637..4c480b0 100644 --- a/docs/07_api_contracts.md +++ b/docs/07_api_contracts.md @@ -72,3 +72,29 @@ - diversity score - fidelity score - unresolved promises + +## 7. Author `.nosbook` 平台导入 +`POST /v1/author/nosbooks/import` + +输入: +- Header 使用 `Authorization: Bearer ` +- Body 是 `.nosbook` JSON envelope +- `schema_version` 必须是 `nosbook/v1` +- 必填:`work / chapters / branch_map / choice_history / quality_summary` + +输出: +- `schema_version = nosbook_import_result/v1` +- `import_id` +- `work_id` +- `status = private_draft` +- `chapter_count` +- `warnings` +- `world_version_link_status = linked | source_only` + +规则: +- 无 author token 返回 `401`,`detail.code = nosbook_import_auth_required` +- 导入只创建作者私有草稿,不进入审核或公开发布 +- active route chapters 写入 `author_work_chapters` +- `branch_map / choice_history / quality_summary / cover` 作为导入元数据保存 +- 源 `world_version_id` 存在时返回 `linked`;不存在时返回 `source_only`,仍允许读取预览但平台续写能力受限 +- 同一账号重复上传同一 checksum 必须返回已有私有草稿,避免 agent 重试生成重复作品 diff --git a/docs/agent_studio_interactive_workbench.md b/docs/agent_studio_interactive_workbench.md index e30ed05..22456e1 100644 --- a/docs/agent_studio_interactive_workbench.md +++ b/docs/agent_studio_interactive_workbench.md @@ -51,6 +51,49 @@ The `.nosbook` export contains: `route=active` exports only the active/main route chapters by default. +## Codex Upload Workflow + +Agent Studio now supports an agent-operated platform upload loop without adding a Studio UI upload button. The default upload result is an Author private draft; it does not submit for review or publish. + +Platform API: + +```http +POST /v1/author/nosbooks/import +Authorization: Bearer +Content-Type: application/vnd.narrativeos.nosbook+json +``` + +The request body is the existing `.nosbook` JSON envelope. The importer requires `schema_version: nosbook/v1` plus `work`, `chapters`, `branch_map`, `choice_history`, and `quality_summary`. Success returns `schema_version: nosbook_import_result/v1`, `work_id`, `status: private_draft`, chapter count, warnings, and `world_version_link_status`. + +Codex-style local upload: + +```bash +export NARRATIVEOS_PLATFORM_URL="https://your-platform.example" +export NARRATIVEOS_PLATFORM_TOKEN="" +python scripts/upload_nosbook.py --file path/to/work.nosbook +``` + +One-command local Studio bridge: + +```bash +export NARRATIVEOS_LOCAL_STUDIO_URL="http://127.0.0.1:8000" +export NARRATIVEOS_LOCAL_STUDIO_TOKEN="" +export NARRATIVEOS_PLATFORM_URL="https://your-platform.example" +export NARRATIVEOS_PLATFORM_TOKEN="" +python scripts/upload_nosbook.py --local-work-id work_xxx +``` + +The CLI prints machine-readable JSON and exits non-zero on failure. It does not print or persist the token. If the source `world_version_id` exists on the platform, the import result is `world_version_link_status: linked`; otherwise it is `source_only`, which is still readable as a private draft but may not support platform continuation until the source world is installed. + +Recommended agent flow: + +1. Start the local Studio with `bash scripts/run_agent_studio_local.sh`. +2. Create or continue the work locally. +3. Set `NARRATIVEOS_LOCAL_STUDIO_TOKEN`, `NARRATIVEOS_PLATFORM_URL`, and `NARRATIVEOS_PLATFORM_TOKEN`. +4. Run `python scripts/upload_nosbook.py --local-work-id work_xxx`. +5. Optionally keep using `--file path/to/work.nosbook` when the agent already has an exported file. +6. Use the returned `work_id` to verify the imported private draft with `GET /v1/author/works/{work_id}`. + ## Local Launch Use the Studio-specific local launcher for author creation sessions: diff --git a/docs/frontend_shell_rebuild.md b/docs/frontend_shell_rebuild.md index f6a8cbc..e1d542e 100644 --- a/docs/frontend_shell_rebuild.md +++ b/docs/frontend_shell_rebuild.md @@ -202,6 +202,26 @@ Use the Agent Studio smoke when validating the Author-side co-directed fiction w The smoke verifies startup, first chapter generation, director continuation, route branching, `.nosbook` export, visible product-language wait copy, desktop workbench rendering at `1440x1000`, desktop sticky director behavior after scrolling to choices/routes, mobile workbench rendering at `390x844`, mobile bounded choice-card scrolling, mobile horizontal overflow, and a visual review checklist for screenshot triage. +Codex-style agents can upload an exported `.nosbook` to a platform private draft through the API/CLI bridge: + +```bash +export NARRATIVEOS_PLATFORM_URL="https://your-platform.example" +export NARRATIVEOS_PLATFORM_TOKEN="" +python scripts/upload_nosbook.py --file path/to/work.nosbook +``` + +For a one-command local Studio bridge, use `--local-work-id` with a separate local author token: + +```bash +export NARRATIVEOS_LOCAL_STUDIO_URL="http://127.0.0.1:8000" +export NARRATIVEOS_LOCAL_STUDIO_TOKEN="" +export NARRATIVEOS_PLATFORM_URL="https://your-platform.example" +export NARRATIVEOS_PLATFORM_TOKEN="" +python scripts/upload_nosbook.py --local-work-id work_xxx +``` + +The CLI calls local `GET /v1/author/works/{work_id}/export?format=nosbook&route=active`, then platform `POST /v1/author/nosbooks/import`, expects `schema_version: nosbook/v1`, prints machine-readable JSON with `schema_version: nosbook_import_result/v1` and `status: private_draft`, and does not print or persist the token. Imports report `world_version_link_status: linked` or `source_only`. The upload is intentionally API/CLI-only in v1; Studio still does not show a platform upload button. + The visual review checklist combines automatic evidence rows with `manual_review` prompts for layout balance, overlap, reader prominence, mobile readability, control reachability, and clipped text. Only objective smoke checks can fail the run. For PRs that change Agent Studio layout CSS, reviewers must paste the two `manual_review` rows from `artifacts/agent_studio_smoke_visual_review.md` into a PR comment and mark each as `accepted` or `needs follow-up` after screenshot inspection: diff --git a/scripts/upload_nosbook.py b/scripts/upload_nosbook.py new file mode 100755 index 0000000..32e51b1 --- /dev/null +++ b/scripts/upload_nosbook.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any, Dict, Optional + + +NOSBOOK_CONTENT_TYPE = "application/vnd.narrativeos.nosbook+json" +IMPORT_PATH = "/v1/author/nosbooks/import" +LOCAL_DEFAULT_URL = "http://127.0.0.1:8000" + + +class NosbookUploadCliError(RuntimeError): + def __init__(self, code: str, message: str, *, exit_code: int = 1, details: Optional[Dict[str, Any]] = None) -> None: + super().__init__(message) + self.code = code + self.message = message + self.exit_code = exit_code + self.details = dict(details or {}) + + +def _json_dump(payload: Dict[str, Any]) -> str: + return json.dumps(payload, ensure_ascii=False, sort_keys=True) + + +def _read_json_file(path: Path) -> Dict[str, Any]: + try: + with path.open("r", encoding="utf-8") as handle: + payload = json.load(handle) + except FileNotFoundError as exc: + raise NosbookUploadCliError("nosbook_file_missing", "nosbook file does not exist", details={"file": str(path)}) from exc + except json.JSONDecodeError as exc: + raise NosbookUploadCliError( + "nosbook_file_invalid_json", + "nosbook file is not valid JSON", + details={"file": str(path), "line": exc.lineno, "column": exc.colno}, + ) from exc + if not isinstance(payload, dict): + raise NosbookUploadCliError("nosbook_file_invalid_json", "nosbook file must contain a JSON object", details={"file": str(path)}) + return payload + + +def _decode_response_body(raw_body: bytes) -> Dict[str, Any]: + try: + decoded = raw_body.decode("utf-8") + except UnicodeDecodeError: + decoded = raw_body.decode("utf-8", errors="replace") + try: + payload = json.loads(decoded or "{}") + except json.JSONDecodeError: + return {"raw": decoded} + return payload if isinstance(payload, dict) else {"raw": payload} + + +def _redact_secret(value: Any, secret: str) -> Any: + if not secret: + return value + if isinstance(value, dict): + return {key: _redact_secret(item, secret) for key, item in value.items()} + if isinstance(value, list): + return [_redact_secret(item, secret) for item in value] + if isinstance(value, str): + return value.replace(secret, "[redacted]") + return value + + +def _redact_secrets(value: Any, *secrets: str) -> Any: + redacted = value + for secret in secrets: + redacted = _redact_secret(redacted, secret) + return redacted + + +def export_local_work_nosbook( + *, + work_id: str, + local_url: str = LOCAL_DEFAULT_URL, + local_token: str, + route: str = "active", + timeout: float = 30.0, +) -> Dict[str, Any]: + normalized_work_id = str(work_id or "").strip() + if not normalized_work_id: + raise NosbookUploadCliError("upload_input_missing", "--local-work-id is required", exit_code=2) + normalized_url = str(local_url or LOCAL_DEFAULT_URL).strip().rstrip("/") or LOCAL_DEFAULT_URL + normalized_token = str(local_token or "").strip() + if not normalized_token: + raise NosbookUploadCliError("missing_local_studio_token", "NARRATIVEOS_LOCAL_STUDIO_TOKEN is required", exit_code=2) + normalized_route = str(route or "active").strip() or "active" + query = urllib.parse.urlencode({"format": "nosbook", "route": normalized_route}) + request = urllib.request.Request( + f"{normalized_url}/v1/author/works/{urllib.parse.quote(normalized_work_id, safe='')}/export?{query}", + headers={ + "Authorization": f"Bearer {normalized_token}", + "Accept": NOSBOOK_CONTENT_TYPE, + }, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + payload = _decode_response_body(response.read()) + except urllib.error.HTTPError as exc: + details = _redact_secret(_decode_response_body(exc.read()), normalized_token) + raise NosbookUploadCliError( + "local_export_failed", + "local Studio export returned an error", + details={"status_code": exc.code, "response": details}, + ) from exc + except urllib.error.URLError as exc: + raise NosbookUploadCliError( + "local_studio_unreachable", + "local Studio API could not be reached", + details={"reason": str(exc.reason)}, + ) from exc + if str(payload.get("schema_version") or "").strip() != "nosbook/v1": + raise NosbookUploadCliError( + "local_export_invalid_nosbook", + "local Studio export did not return a nosbook/v1 envelope", + details={"schema_version": payload.get("schema_version")}, + ) + return payload + + +def upload_nosbook_payload( + payload: Dict[str, Any], + *, + platform_url: str, + token: str, + timeout: float = 30.0, +) -> Dict[str, Any]: + normalized_url = str(platform_url or "").strip().rstrip("/") + if not normalized_url: + raise NosbookUploadCliError("missing_platform_url", "NARRATIVEOS_PLATFORM_URL is required", exit_code=2) + normalized_token = str(token or "").strip() + if not normalized_token: + raise NosbookUploadCliError("missing_platform_token", "NARRATIVEOS_PLATFORM_TOKEN is required", exit_code=2) + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + request = urllib.request.Request( + f"{normalized_url}{IMPORT_PATH}", + data=body, + headers={ + "Authorization": f"Bearer {normalized_token}", + "Content-Type": NOSBOOK_CONTENT_TYPE, + "Accept": "application/json", + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return _decode_response_body(response.read()) + except urllib.error.HTTPError as exc: + details = _redact_secret(_decode_response_body(exc.read()), normalized_token) + raise NosbookUploadCliError( + "platform_upload_failed", + "platform returned an error", + details={"status_code": exc.code, "response": details}, + ) from exc + except urllib.error.URLError as exc: + raise NosbookUploadCliError( + "platform_unreachable", + "platform API could not be reached", + details={"reason": str(exc.reason)}, + ) from exc + + +def upload_nosbook_file( + path: Path, + *, + platform_url: str, + token: str, + timeout: float = 30.0, +) -> Dict[str, Any]: + return upload_nosbook_payload( + _read_json_file(path), + platform_url=platform_url, + token=token, + timeout=timeout, + ) + + +def upload_nosbook_from_local_work( + *, + work_id: str, + local_url: str, + local_token: str, + local_route: str, + platform_url: str, + platform_token: str, + timeout: float = 30.0, +) -> Dict[str, Any]: + payload = export_local_work_nosbook( + work_id=work_id, + local_url=local_url, + local_token=local_token, + route=local_route, + timeout=timeout, + ) + return upload_nosbook_payload( + payload, + platform_url=platform_url, + token=platform_token, + timeout=timeout, + ) + + +def _error_payload(error: NosbookUploadCliError) -> Dict[str, Any]: + return { + "schema_version": "nosbook_upload_cli_error/v1", + "ok": False, + "code": error.code, + "message": error.message, + **({"details": error.details} if error.details else {}), + } + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser(description="Upload a NarrativeOS .nosbook export to the platform as a private draft.") + parser.add_argument("--file", help="Path to a .nosbook JSON envelope.") + parser.add_argument("--local-work-id", help="Export this local Agent Studio AuthorWork before uploading.") + parser.add_argument("--local-url", default=os.environ.get("NARRATIVEOS_LOCAL_STUDIO_URL", LOCAL_DEFAULT_URL)) + parser.add_argument("--local-token", default=os.environ.get("NARRATIVEOS_LOCAL_STUDIO_TOKEN", "")) + parser.add_argument("--local-route", default="active") + parser.add_argument("--platform-url", default=os.environ.get("NARRATIVEOS_PLATFORM_URL", "")) + parser.add_argument("--token", dest="platform_token", default=None) + parser.add_argument("--platform-token", dest="platform_token", default=None) + parser.add_argument("--timeout", type=float, default=30.0) + args = parser.parse_args(argv) + platform_token = args.platform_token if args.platform_token is not None else os.environ.get("NARRATIVEOS_PLATFORM_TOKEN", "") + + try: + if args.file and args.local_work_id: + raise NosbookUploadCliError("upload_input_conflict", "use either --file or --local-work-id, not both", exit_code=2) + if not args.file and not args.local_work_id: + raise NosbookUploadCliError("upload_input_missing", "either --file or --local-work-id is required", exit_code=2) + if args.local_work_id: + result = upload_nosbook_from_local_work( + work_id=args.local_work_id, + local_url=args.local_url, + local_token=args.local_token, + local_route=args.local_route, + platform_url=args.platform_url, + platform_token=platform_token, + timeout=args.timeout, + ) + else: + result = upload_nosbook_file( + Path(args.file), + platform_url=args.platform_url, + token=platform_token, + timeout=args.timeout, + ) + except NosbookUploadCliError as exc: + error_payload = _redact_secrets( + _error_payload(exc), + str(args.local_token or ""), + str(platform_token or ""), + ) + print(_json_dump(error_payload)) + return exc.exit_code + print(_json_dump(result)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/narrativeos/api/app_factory.py b/src/narrativeos/api/app_factory.py index 46c85f5..3945607 100644 --- a/src/narrativeos/api/app_factory.py +++ b/src/narrativeos/api/app_factory.py @@ -71,6 +71,7 @@ from ..services.launch_week_guard import LaunchWeekGuardService from ..services.launch_week_monitoring import LaunchWeekMonitoringService from ..services.monetization import MonetizationService +from ..services.nosbook_import import NosbookImportService from ..services.observability import ObservabilityService from ..services.ops_traceability import OpsTraceabilityService from ..services.ops_alerting import OpsAlertingService @@ -587,6 +588,7 @@ def _schedule_async_job_thread(target, job_id: str) -> None: provider_routing_service=app.state.provider_routing_service, analytics_service=app.state.analytics_service, ) + app.state.nosbook_import_service = NosbookImportService(app.state.repository) app.state.library_stats_semantic_layer_service = LibraryStatsSemanticLayerService( app.state.repository, ) diff --git a/src/narrativeos/api/author.py b/src/narrativeos/api/author.py index d174fe2..4942348 100644 --- a/src/narrativeos/api/author.py +++ b/src/narrativeos/api/author.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, Field from ..eval.service import ChapterQualityGuardError +from ..services.nosbook_import import NosbookImportError class SaveDraftRequest(BaseModel): @@ -1178,6 +1179,26 @@ def list_author_works( ) +@router.post("/nosbooks/import") +def import_author_nosbook(payload: Dict[str, Any], request: Request) -> Dict[str, Any]: + identity = _authenticated_author_identity( + request, + missing_code="nosbook_import_auth_required", + missing_reason="platform_bearer_token_required", + ) + account_id = _author_identity_account_id(identity) + if not account_id: + raise HTTPException(status_code=401, detail={"code": "nosbook_import_auth_required", "reason": "account_id_required"}) + try: + return request.app.state.nosbook_import_service.import_nosbook( + payload, + account_id=account_id, + actor_id=_author_identity_actor_id(identity), + ) + except NosbookImportError as exc: + raise HTTPException(status_code=400, detail=exc.detail()) from exc + + @router.post("/works") def create_author_work(payload: AuthorWorkCreateRequest, request: Request) -> Dict[str, Any]: version = request.app.state.repository.get_world_version(payload.world_version_id) diff --git a/src/narrativeos/services/nosbook_import.py b/src/narrativeos/services/nosbook_import.py new file mode 100644 index 0000000..e6a8117 --- /dev/null +++ b/src/narrativeos/services/nosbook_import.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional +from uuid import uuid4 + +from ..persistence.repositories import SQLAlchemyPlatformRepository + + +NOSBOOK_SCHEMA_VERSION = "nosbook/v1" +NOSBOOK_IMPORT_RESULT_SCHEMA_VERSION = "nosbook_import_result/v1" +NOSBOOK_CONTENT_TYPE = "application/vnd.narrativeos.nosbook+json" +REQUIRED_NOSBOOK_FIELDS = { + "work", + "chapters", + "branch_map", + "choice_history", + "quality_summary", +} + + +class NosbookImportError(ValueError): + def __init__(self, code: str, reason: str, *, details: Optional[Dict[str, Any]] = None) -> None: + super().__init__(reason) + self.code = code + self.reason = reason + self.details = dict(details or {}) + + def detail(self) -> Dict[str, Any]: + return { + "code": self.code, + "reason": self.reason, + **({"details": self.details} if self.details else {}), + } + + +class NosbookImportService: + def __init__(self, repository: SQLAlchemyPlatformRepository) -> None: + self.repository = repository + + def _utcnow(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _canonical_checksum(self, envelope: Dict[str, Any]) -> str: + encoded = json.dumps( + envelope, + sort_keys=True, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + def _require_mapping(self, payload: Any, *, code: str, reason: str) -> Dict[str, Any]: + if not isinstance(payload, dict): + raise NosbookImportError(code, reason) + return dict(payload) + + def _validate_envelope(self, envelope: Any) -> Dict[str, Any]: + payload = self._require_mapping( + envelope, + code="malformed_nosbook", + reason="nosbook_envelope_must_be_json_object", + ) + schema_version = str(payload.get("schema_version") or "").strip() + if schema_version != NOSBOOK_SCHEMA_VERSION: + raise NosbookImportError( + "unsupported_nosbook_schema", + "nosbook_schema_version_must_be_nosbook_v1", + details={"schema_version": schema_version or None}, + ) + missing = sorted(field for field in REQUIRED_NOSBOOK_FIELDS if field not in payload) + if missing: + raise NosbookImportError( + "malformed_nosbook", + "nosbook_required_fields_missing", + details={"missing_fields": missing}, + ) + work = self._require_mapping( + payload.get("work"), + code="malformed_nosbook", + reason="nosbook_work_must_be_json_object", + ) + chapters = payload.get("chapters") + if not isinstance(chapters, list) or not chapters: + raise NosbookImportError( + "malformed_nosbook", + "nosbook_chapters_must_be_non_empty_array", + ) + branch_map = payload.get("branch_map") + if not isinstance(branch_map, list): + raise NosbookImportError("malformed_nosbook", "nosbook_branch_map_must_be_array") + choice_history = payload.get("choice_history") + if not isinstance(choice_history, list): + raise NosbookImportError("malformed_nosbook", "nosbook_choice_history_must_be_array") + quality_summary = payload.get("quality_summary") + if not isinstance(quality_summary, dict): + raise NosbookImportError("malformed_nosbook", "nosbook_quality_summary_must_be_json_object") + normalized_chapters: List[Dict[str, Any]] = [] + for index, chapter in enumerate(chapters, start=1): + item = self._require_mapping( + chapter, + code="malformed_nosbook", + reason="nosbook_chapter_must_be_json_object", + ) + body = str(item.get("body") or "").strip() + if not body: + raise NosbookImportError( + "malformed_nosbook", + "nosbook_chapter_body_required", + details={"chapter_position": index}, + ) + chapter_index = int(item.get("chapter_index") or index) + normalized_chapters.append({**item, "chapter_index": chapter_index, "body": body}) + return { + **payload, + "work": work, + "chapters": normalized_chapters, + "branch_map": [dict(item) for item in branch_map if isinstance(item, dict)], + "choice_history": [dict(item) for item in choice_history if isinstance(item, dict)], + "quality_summary": dict(quality_summary), + } + + def _world_version_link_status(self, world_version_id: str, warnings: List[Dict[str, Any]]) -> str: + if not world_version_id: + warnings.append( + { + "code": "missing_source_world_version_id", + "message": "Imported as source-only because the nosbook did not include a source world version.", + } + ) + return "source_only" + try: + self.repository.get_world_version(world_version_id) + except KeyError: + warnings.append( + { + "code": "source_world_version_not_found", + "message": "Imported as source-only; platform continuation may be limited until the source world exists.", + "world_version_id": world_version_id, + } + ) + return "source_only" + return "linked" + + def _duplicate_result( + self, + *, + account_id: str, + checksum: str, + import_id: str, + world_version_link_status: str, + warnings: List[Dict[str, Any]], + ) -> Optional[Dict[str, Any]]: + for work in self.repository.list_author_works(account_id=account_id, limit=500): + diagnostics = dict(work.get("diagnostics_summary_json") or {}) + metadata = dict(diagnostics.get("nosbook_import") or {}) + if str(metadata.get("checksum") or "") != checksum: + continue + return { + "schema_version": NOSBOOK_IMPORT_RESULT_SCHEMA_VERSION, + "import_id": str(metadata.get("import_id") or import_id), + "work_id": work["work_id"], + "status": "private_draft", + "chapter_count": int(work.get("chapter_count") or 0), + "warnings": list(warnings), + "world_version_link_status": str(metadata.get("world_version_link_status") or world_version_link_status), + "duplicate": True, + "duplicate_status": "existing_private_draft", + } + return None + + def import_nosbook(self, envelope: Any, *, account_id: str, actor_id: Optional[str] = None) -> Dict[str, Any]: + normalized_account_id = str(account_id or "").strip() + if not normalized_account_id: + raise NosbookImportError("nosbook_import_account_required", "account_id_required") + payload = self._validate_envelope(envelope) + checksum = self._canonical_checksum(payload) + import_id = f"nosbook_import_{checksum[:16]}" + warnings: List[Dict[str, Any]] = [] + work_payload = dict(payload.get("work") or {}) + source_world_version_id = str(work_payload.get("world_version_id") or "").strip() + world_version_id = source_world_version_id or f"source_only_nosbook_{checksum[:12]}" + world_link_status = self._world_version_link_status(source_world_version_id, warnings) + + duplicate = self._duplicate_result( + account_id=normalized_account_id, + checksum=checksum, + import_id=import_id, + world_version_link_status=world_link_status, + warnings=warnings, + ) + if duplicate: + return duplicate + + chapters = list(payload["chapters"]) + title = str(work_payload.get("title") or "Imported NarrativeOS Work").strip() or "Imported NarrativeOS Work" + imported_at = self._utcnow() + import_metadata = { + "schema_version": "nosbook_import_metadata/v1", + "import_id": import_id, + "checksum": checksum, + "actor_id": str(actor_id or "") or None, + "account_id": normalized_account_id, + "imported_at": imported_at, + "source_world_version_id": source_world_version_id or None, + "world_version_id": world_version_id, + "world_version_link_status": world_link_status, + "source_route_name": str((payload.get("export_route") or {}).get("route_name") or work_payload.get("route_name") or "").strip() or None, + "branch_map": list(payload.get("branch_map") or []), + "choice_history": list(payload.get("choice_history") or []), + "quality_summary": dict(payload.get("quality_summary") or {}), + "cover": dict(payload.get("cover") or {}), + } + work = self.repository.save_author_work( + { + "world_version_id": world_version_id, + "account_id": normalized_account_id, + "title": title, + "status": "draft", + "chapter_count": len(chapters), + "target_chapter_count": int(work_payload.get("target_chapter_count") or len(chapters) or 0), + "branch_name": "主线", + "branch_kind": "mainline", + "fork_after_chapter_index": 0, + "is_active_line": True, + "narrative_state_json": { + "state_id": f"{import_id}::state", + "metadata": { + "nosbook_import": import_metadata, + }, + }, + "diagnostics_summary_json": { + "nosbook_import": import_metadata, + "source": "nosbook_import", + }, + } + ) + work = self.repository.save_author_work( + { + **work, + "root_work_id": work["work_id"], + "branch_id": work["work_id"], + "is_active_line": True, + } + ) + for position, chapter in enumerate(chapters, start=1): + chapter_index = int(chapter.get("chapter_index") or position) + self.repository.save_author_work_chapter( + { + "chapter_record_id": f"nosbook_chapter_{uuid4().hex[:12]}", + "work_id": work["work_id"], + "chapter_index": chapter_index, + "chapter_title": str(chapter.get("chapter_title") or f"第 {chapter_index} 章"), + "body": str(chapter.get("body") or ""), + "status": "generated", + "source_type": "nosbook_import", + "summary": str(chapter.get("summary") or ""), + "diagnostic_summary_json": {}, + "chapter_task_json": { + "source": "nosbook_import", + "choice_impacts": list(chapter.get("choice_impacts") or []), + }, + "choices_json": list(chapter.get("choices") or []), + "state_snapshot_json": { + "state_id": f"{import_id}::chapter::{chapter_index}", + "metadata": { + "nosbook_import_id": import_id, + "source_chapter_index": chapter_index, + }, + }, + } + ) + revision = self.repository.save_author_work_revision( + { + "work_id": work["work_id"], + "revision_type": "nosbook_imported", + "summary": "导入 .nosbook 为作者私有草稿", + "snapshot_json": { + "nosbook_import": import_metadata, + "chapter_count": len(chapters), + }, + } + ) + self.repository.save_author_work({**work, "current_revision": revision["revision_id"], "chapter_count": len(chapters)}) + return { + "schema_version": NOSBOOK_IMPORT_RESULT_SCHEMA_VERSION, + "import_id": import_id, + "work_id": work["work_id"], + "status": "private_draft", + "chapter_count": len(chapters), + "warnings": warnings, + "world_version_link_status": world_link_status, + "duplicate": False, + "duplicate_status": None, + } diff --git a/tests/test_frontend_shell_docs.py b/tests/test_frontend_shell_docs.py index 911452b..de736ae 100644 --- a/tests/test_frontend_shell_docs.py +++ b/tests/test_frontend_shell_docs.py @@ -203,3 +203,39 @@ def test_agent_studio_layout_pr_review_convention_is_documented(): combined = "\n".join([pr_template, review_template, studio_doc, frontend_doc]).lower() for phrase in forbidden: assert phrase not in combined + + +def test_agent_studio_docs_define_codex_nosbook_upload_workflow(): + studio_doc = (ROOT / "docs" / "agent_studio_interactive_workbench.md").read_text(encoding="utf-8") + frontend_doc = (ROOT / "docs" / "frontend_shell_rebuild.md").read_text(encoding="utf-8") + api_contracts = (ROOT / "docs" / "07_api_contracts.md").read_text(encoding="utf-8") + upload_script = (ROOT / "scripts" / "upload_nosbook.py").read_text(encoding="utf-8") + + for text in [studio_doc, frontend_doc, api_contracts]: + assert "POST /v1/author/nosbooks/import" in text + assert "nosbook_import_result/v1" in text + assert "private_draft" in text + assert "source_only" in text + + for text in [studio_doc, frontend_doc]: + assert "NARRATIVEOS_PLATFORM_URL" in text + assert "NARRATIVEOS_PLATFORM_TOKEN" in text + assert "NARRATIVEOS_LOCAL_STUDIO_URL" in text + assert "NARRATIVEOS_LOCAL_STUDIO_TOKEN" in text + assert "scripts/upload_nosbook.py --file" in text + assert "scripts/upload_nosbook.py --local-work-id" in text + assert "does not print or persist the token" in text + + assert "nosbook_import_auth_required" in api_contracts + assert "author_work_chapters" in api_contracts + + assert "NOSBOOK_CONTENT_TYPE" in upload_script + assert "application/vnd.narrativeos.nosbook+json" in upload_script + assert "/v1/author/nosbooks/import" in upload_script + assert "--local-work-id" in upload_script + assert "NARRATIVEOS_LOCAL_STUDIO_URL" in upload_script + assert "NARRATIVEOS_LOCAL_STUDIO_TOKEN" in upload_script + assert "http://127.0.0.1:8000" in upload_script + assert "local_export_invalid_nosbook" in upload_script + assert "Authorization" in upload_script + assert "NARRATIVEOS_PLATFORM_TOKEN" in upload_script diff --git a/tests/test_nosbook_platform_upload.py b/tests/test_nosbook_platform_upload.py new file mode 100644 index 0000000..af98b80 --- /dev/null +++ b/tests/test_nosbook_platform_upload.py @@ -0,0 +1,529 @@ +from __future__ import annotations + +import importlib.util +import io +import json +import os +import subprocess +import sys +import urllib.error +from pathlib import Path +from typing import Any, Dict + +import pytest +from fastapi.testclient import TestClient + +from src.narrativeos.api import create_app +from src.narrativeos.models import NarrativeState +from src.narrativeos.repository import SQLAlchemyRepository + + +ROOT = Path(__file__).resolve().parents[1] + + +def _auth_headers(client: TestClient, *, actor_id: str, actor_role: str = "author", password: str = "secret123") -> dict[str, str]: + client.post( + "/v1/auth/register", + json={ + "actor_id": actor_id, + "actor_role": actor_role, + "password": password, + "account_id": actor_id, + }, + ) + login = client.post("/v1/auth/login", json={"actor_id": actor_id, "password": password}) + assert login.status_code == 200 + return {"Authorization": f"Bearer {login.json()['token']['access_token']}"} + + +def _state(chapter_index: int = 1) -> dict: + return NarrativeState.from_dict( + { + "state_id": f"nosbook_upload_state_{chapter_index}", + "world_id": "nosbook_upload_world", + "turn_index": chapter_index, + "story_phase": "setup", + "chapter_index": chapter_index, + "min_end_turn": 8, + "fate_pressure": 0.0, + "karmic_weather": {}, + "unresolved_debts": [], + "world_facts": [], + "timeline": [], + "characters": {}, + "relationship_graph": [], + "open_promises": [], + "tension": 0.3, + "themes": {}, + "player_intent": {}, + "recent_scene_functions": [], + "visited_event_ids": [], + "route_fingerprint": [], + "rating_ceiling": "PG13", + } + ).to_dict() + + +def _minimal_nosbook(*, world_version_id: str = "unknown_world_version") -> Dict[str, Any]: + return { + "schema_version": "nosbook/v1", + "content_type": "application/vnd.narrativeos.nosbook+json", + "filename": "import-test.nosbook", + "work": { + "title": "上传闭环测试", + "world_version_id": world_version_id, + "route_name": "主线", + "chapter_count": 1, + "target_chapter_count": 10, + }, + "export_route": {"route": "active", "route_name": "主线", "is_active_line": True}, + "chapters": [ + { + "chapter_index": 1, + "chapter_title": "第 1 章 · 雾港", + "body": "雾从码头压下来。\n\n林澈把证据藏进衣袋,决定先相信证人。", + "summary": "主角保留证据并相信证人。", + "choices": ["追查证据", "保护证人"], + "choice_impacts": [ + { + "choice_id": "choice_1_1", + "label": "追查证据", + "risk_level": "高", + "emotion": "冲突", + "pacing": "推进", + "relationship": "怀疑", + "mystery": "加深", + } + ], + } + ], + "branch_map": [ + { + "route_name": "主线", + "current_chapter_count": 1, + "recent_choice": "主线推进", + "quality_status": "稳定", + "is_export_main_route": True, + "fork_after_chapter_index": 0, + } + ], + "choice_history": [ + { + "route_name": "主线", + "chapter_index": 1, + "selected_choice": "保护证人", + "expected_effect": "增强人物关系", + } + ], + "quality_summary": { + "重复感": "良好", + "场景细节": "充足", + "节奏": "稳定", + "结尾风险": "正常", + }, + "cover": {"mode": "default", "source": "agent_studio_default_cover", "metadata": {}}, + } + + +def _load_upload_module(): + spec = importlib.util.spec_from_file_location("upload_nosbook", ROOT / "scripts" / "upload_nosbook.py") + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_nosbook_import_api_requires_bearer_token(tmp_path: Path): + app = create_app(repository=SQLAlchemyRepository(database_url=f"sqlite:///{tmp_path / 'missing_token.db'}")) + client = TestClient(app) + + response = client.post("/v1/author/nosbooks/import", json=_minimal_nosbook()) + + assert response.status_code == 401 + assert response.json()["detail"]["code"] == "nosbook_import_auth_required" + + +def test_nosbook_import_rejects_invalid_schema_and_empty_chapters(tmp_path: Path): + app = create_app(repository=SQLAlchemyRepository(database_url=f"sqlite:///{tmp_path / 'invalid_schema.db'}")) + client = TestClient(app) + headers = _auth_headers(client, actor_id="nosbook_invalid_author") + + invalid_schema = _minimal_nosbook() + invalid_schema["schema_version"] = "nosbook/v2" + invalid_response = client.post("/v1/author/nosbooks/import", headers=headers, json=invalid_schema) + assert invalid_response.status_code == 400 + assert invalid_response.json()["detail"]["code"] == "unsupported_nosbook_schema" + + empty_chapters = _minimal_nosbook() + empty_chapters["chapters"] = [] + empty_response = client.post("/v1/author/nosbooks/import", headers=headers, json=empty_chapters) + assert empty_response.status_code == 400 + assert empty_response.json()["detail"]["reason"] == "nosbook_chapters_must_be_non_empty_array" + + +def test_nosbook_import_unknown_world_creates_source_only_private_draft(tmp_path: Path): + repository = SQLAlchemyRepository(database_url=f"sqlite:///{tmp_path / 'source_only.db'}") + app = create_app(repository=repository) + client = TestClient(app) + headers = _auth_headers(client, actor_id="nosbook_source_only_author") + + response = client.post("/v1/author/nosbooks/import", headers=headers, json=_minimal_nosbook(world_version_id="missing_world_version")) + + assert response.status_code == 200 + result = response.json() + assert result["schema_version"] == "nosbook_import_result/v1" + assert result["status"] == "private_draft" + assert result["world_version_link_status"] == "source_only" + assert result["chapter_count"] == 1 + assert result["warnings"][0]["code"] == "source_world_version_not_found" + + detail = client.get(f"/v1/author/works/{result['work_id']}", headers=headers) + assert detail.status_code == 200 + assert detail.json()["title"] == "上传闭环测试" + assert detail.json()["chapters"][0]["body"].startswith("雾从码头压下来。") + + +def test_nosbook_import_from_agent_studio_export_restores_metadata_and_dedupes(tmp_path: Path): + repository = SQLAlchemyRepository(database_url=f"sqlite:///{tmp_path / 'round_trip.db'}") + app = create_app(repository=repository) + client = TestClient(app) + source_headers = _auth_headers(client, actor_id="nosbook_source_author") + target_headers = _auth_headers(client, actor_id="nosbook_target_author") + + draft = app.state.authoring_service.create_draft_from_brief( + { + "genre_preset": "urban_mystery", + "world_title": "Nosbook Upload Export", + "lead_name": "林澈", + "counterpart_name": "沈知", + "core_premise": "验证 Codex 类 agent 上传 .nosbook 的闭环。", + "life_theme": "信任与隐瞒", + "author_id": "nosbook_source_author", + "account_id": "nosbook_source_author", + } + ) + work = app.state.author_work_service.create_work( + world_version_id=draft["world_version_id"], + account_id="nosbook_source_author", + ) + repository.save_author_work_chapter( + { + "work_id": work["work_id"], + "chapter_index": 1, + "chapter_title": "第 1 章 · 雾港", + "body": "雨线把码头切成几段。\n\n林澈听见证人在仓库后门轻敲三下。", + "summary": "主角抵达雾港并遇到证人。", + "choices_json": ["追查证据", "保护证人", "隐藏真相"], + "state_snapshot_json": _state(1), + } + ) + app.state.author_work_service.create_branch( + work_id=work["work_id"], + source_chapter_index=1, + label="路线 A:保护证人", + choice_source="保护证人", + steering_directive={"current_user_intent": "增强人物关系", "summary": "增强人物关系"}, + ) + export_response = client.get( + f"/v1/author/works/{work['work_id']}/export", + headers=source_headers, + params={"format": "nosbook", "route": "active"}, + ) + assert export_response.status_code == 200 + nosbook_payload = export_response.json() + + first_import = client.post("/v1/author/nosbooks/import", headers=target_headers, json=nosbook_payload) + assert first_import.status_code == 200 + first_result = first_import.json() + assert first_result["status"] == "private_draft" + assert first_result["world_version_link_status"] == "linked" + assert first_result["duplicate"] is False + + stored_work = repository.get_author_work(first_result["work_id"]) + import_metadata = stored_work["diagnostics_summary_json"]["nosbook_import"] + assert import_metadata["branch_map"] + assert import_metadata["choice_history"][0]["selected_choice"] == "保护证人" + assert import_metadata["quality_summary"]["重复感"] == "良好" + + chapters = repository.list_author_work_chapters(work_id=first_result["work_id"]) + assert len(chapters) == first_result["chapter_count"] + assert chapters[0]["source_type"] == "nosbook_import" + assert chapters[0]["choices_json"] == ["追查证据", "保护证人", "隐藏真相"] + + detail = client.get(f"/v1/author/works/{first_result['work_id']}", headers=target_headers) + assert detail.status_code == 200 + assert detail.json()["chapters"][0]["chapter_title"] == "第 1 章 · 雾港" + + second_import = client.post("/v1/author/nosbooks/import", headers=target_headers, json=nosbook_payload) + assert second_import.status_code == 200 + duplicate_result = second_import.json() + assert duplicate_result["work_id"] == first_result["work_id"] + assert duplicate_result["duplicate"] is True + assert duplicate_result["duplicate_status"] == "existing_private_draft" + + +def test_upload_nosbook_cli_missing_token_fails_without_secret_leak(tmp_path: Path): + nosbook_file = tmp_path / "work.nosbook" + nosbook_file.write_text(json.dumps(_minimal_nosbook(), ensure_ascii=False), encoding="utf-8") + env = {**os.environ, "NARRATIVEOS_PLATFORM_URL": "http://127.0.0.1:1"} + env.pop("NARRATIVEOS_PLATFORM_TOKEN", None) + + result = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "upload_nosbook.py"), "--file", str(nosbook_file)], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 2 + payload = json.loads(result.stdout) + assert payload["code"] == "missing_platform_token" + assert "NARRATIVEOS_PLATFORM_TOKEN" in payload["message"] + assert "Bearer" not in result.stdout + + +def test_upload_nosbook_cli_mock_success_outputs_platform_json(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + upload_module = _load_upload_module() + nosbook_file = tmp_path / "work.nosbook" + nosbook_file.write_text(json.dumps(_minimal_nosbook(), ensure_ascii=False), encoding="utf-8") + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return b'{"schema_version":"nosbook_import_result/v1","work_id":"work_uploaded","status":"private_draft"}' + + def fake_urlopen(request, timeout): + assert request.full_url == "https://platform.example/v1/author/nosbooks/import" + assert request.headers["Authorization"] == "Bearer secret-token" + assert timeout == 12 + return FakeResponse() + + monkeypatch.setattr(upload_module.urllib.request, "urlopen", fake_urlopen) + + result = upload_module.upload_nosbook_file( + nosbook_file, + platform_url="https://platform.example/", + token="secret-token", + timeout=12, + ) + + assert result["schema_version"] == "nosbook_import_result/v1" + assert result["work_id"] == "work_uploaded" + + +def test_upload_nosbook_cli_local_work_id_exports_then_uploads_with_separate_tokens(monkeypatch: pytest.MonkeyPatch): + upload_module = _load_upload_module() + calls = [] + + class FakeResponse: + def __init__(self, payload: Dict[str, Any]): + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return json.dumps(self.payload, ensure_ascii=False).encode("utf-8") + + def fake_urlopen(request, timeout): + calls.append(request) + if request.full_url.startswith("http://127.0.0.1:8000/v1/author/works/work_123/export"): + assert request.get_method() == "GET" + assert request.headers["Authorization"] == "Bearer local-secret" + assert "format=nosbook" in request.full_url + assert "route=main" in request.full_url + assert timeout == 9 + return FakeResponse(_minimal_nosbook(world_version_id="linked_world")) + assert request.full_url == "https://platform.example/v1/author/nosbooks/import" + assert request.get_method() == "POST" + assert request.headers["Authorization"] == "Bearer platform-secret" + assert json.loads(request.data.decode("utf-8"))["schema_version"] == "nosbook/v1" + return FakeResponse( + { + "schema_version": "nosbook_import_result/v1", + "work_id": "work_uploaded_from_local", + "status": "private_draft", + } + ) + + monkeypatch.setattr(upload_module.urllib.request, "urlopen", fake_urlopen) + + result = upload_module.upload_nosbook_from_local_work( + work_id="work_123", + local_url="http://127.0.0.1:8000", + local_token="local-secret", + local_route="main", + platform_url="https://platform.example", + platform_token="platform-secret", + timeout=9, + ) + + assert len(calls) == 2 + assert result["work_id"] == "work_uploaded_from_local" + + +def test_upload_nosbook_cli_local_work_id_requires_local_token_and_keeps_tokens_secret(tmp_path: Path): + env = { + **os.environ, + "NARRATIVEOS_PLATFORM_URL": "https://platform.example", + "NARRATIVEOS_PLATFORM_TOKEN": "platform-secret", + } + env.pop("NARRATIVEOS_LOCAL_STUDIO_TOKEN", None) + + result = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "upload_nosbook.py"), "--local-work-id", "work_123"], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 2 + payload = json.loads(result.stdout) + assert payload["code"] == "missing_local_studio_token" + assert "NARRATIVEOS_LOCAL_STUDIO_TOKEN" in payload["message"] + assert "platform-secret" not in result.stdout + + +def test_upload_nosbook_cli_rejects_conflicting_or_missing_inputs(tmp_path: Path): + nosbook_file = tmp_path / "work.nosbook" + nosbook_file.write_text(json.dumps(_minimal_nosbook(), ensure_ascii=False), encoding="utf-8") + env = { + **os.environ, + "NARRATIVEOS_PLATFORM_URL": "https://platform.example", + "NARRATIVEOS_PLATFORM_TOKEN": "platform-secret", + "NARRATIVEOS_LOCAL_STUDIO_TOKEN": "local-secret", + } + + conflict = subprocess.run( + [ + sys.executable, + str(ROOT / "scripts" / "upload_nosbook.py"), + "--file", + str(nosbook_file), + "--local-work-id", + "work_123", + ], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + assert conflict.returncode == 2 + assert json.loads(conflict.stdout)["code"] == "upload_input_conflict" + + missing = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "upload_nosbook.py")], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + assert missing.returncode == 2 + assert json.loads(missing.stdout)["code"] == "upload_input_missing" + + +def test_upload_nosbook_cli_local_export_http_error_is_diagnostic_and_token_safe(monkeypatch: pytest.MonkeyPatch): + upload_module = _load_upload_module() + + def fake_urlopen(request, timeout): + raise urllib.error.HTTPError( + request.full_url, + 403, + "Forbidden", + hdrs=None, + fp=io.BytesIO(b'{"detail":{"code":"author_work_forbidden","echo":"local-secret"}}'), + ) + + monkeypatch.setattr(upload_module.urllib.request, "urlopen", fake_urlopen) + + with pytest.raises(upload_module.NosbookUploadCliError) as exc_info: + upload_module.upload_nosbook_from_local_work( + work_id="work_123", + local_url="http://127.0.0.1:8000", + local_token="local-secret", + local_route="active", + platform_url="https://platform.example", + platform_token="platform-secret", + timeout=9, + ) + + payload = upload_module._error_payload(exc_info.value) + serialized = json.dumps(payload) + assert payload["code"] == "local_export_failed" + assert payload["details"]["status_code"] == 403 + assert "author_work_forbidden" in serialized + assert "local-secret" not in serialized + assert "platform-secret" not in serialized + + +def test_upload_nosbook_cli_local_export_rejects_non_nosbook_payload(monkeypatch: pytest.MonkeyPatch): + upload_module = _load_upload_module() + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return b'{"schema_version":"nosbook/v2"}' + + monkeypatch.setattr(upload_module.urllib.request, "urlopen", lambda request, timeout: FakeResponse()) + + with pytest.raises(upload_module.NosbookUploadCliError) as exc_info: + upload_module.export_local_work_nosbook( + work_id="work_123", + local_url="http://127.0.0.1:8000", + local_token="local-secret", + route="active", + timeout=9, + ) + + assert exc_info.value.code == "local_export_invalid_nosbook" + + +def test_upload_nosbook_cli_mock_platform_error_is_diagnostic_and_token_safe(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + upload_module = _load_upload_module() + nosbook_file = tmp_path / "work.nosbook" + nosbook_file.write_text(json.dumps(_minimal_nosbook(), ensure_ascii=False), encoding="utf-8") + + def fake_urlopen(request, timeout): + raise urllib.error.HTTPError( + request.full_url, + 400, + "Bad Request", + hdrs=None, + fp=io.BytesIO(b'{"detail":{"code":"malformed_nosbook","echo":"secret-token"}}'), + ) + + monkeypatch.setattr(upload_module.urllib.request, "urlopen", fake_urlopen) + + with pytest.raises(upload_module.NosbookUploadCliError) as exc_info: + upload_module.upload_nosbook_file( + nosbook_file, + platform_url="https://platform.example", + token="secret-token", + timeout=12, + ) + + payload = upload_module._error_payload(exc_info.value) + serialized = json.dumps(payload) + assert payload["code"] == "platform_upload_failed" + assert payload["details"]["status_code"] == 400 + assert "malformed_nosbook" in serialized + assert "secret-token" not in serialized