@oh-story/dsh 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -14
- package/lib/client.js +237 -84
- package/lib/client.js.map +3 -3
- package/lib/drama/manifest.json +183 -77
- package/lib/drama/skills/short-drama/SKILL.md +5 -1
- package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +140 -9
- package/lib/drama/skills/short-drama/scripts/project_tool.py +11 -0
- package/lib/drama/skills/short-drama-assets/references/character-and-look.md +28 -0
- package/lib/drama/skills/short-drama-assets/references/continuity-lock.md +8 -2
- package/lib/drama/skills/short-drama-assets/references/voice-direction.md +28 -0
- package/lib/drama/skills/short-drama-edit/SKILL.md +146 -0
- package/lib/drama/skills/short-drama-edit/agents/openai.yaml +4 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/README.md +60 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/package.json +15 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/remotion.config.ts +6 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/Root.tsx +26 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/Subtitles.tsx +88 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/font.ts +46 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/index.ts +4 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/schema.ts +32 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/tsconfig.json +13 -0
- package/lib/drama/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +43 -0
- package/lib/drama/skills/short-drama-edit/references/cut-craft.md +38 -0
- package/lib/drama/skills/short-drama-edit/references/delivery-verify.md +29 -0
- package/lib/drama/skills/short-drama-edit/references/generated-footage.md +54 -0
- package/lib/drama/skills/short-drama-edit/references/sound-and-subtitles.md +60 -0
- package/lib/drama/skills/short-drama-edit/references/stage-contract.md +51 -0
- package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +1156 -0
- package/lib/drama/skills/short-drama-edit/scripts/selftest.py +520 -0
- package/lib/drama/skills/short-drama-image-prompts/SKILL.md +2 -0
- package/lib/drama/skills/short-drama-image-prompts/assets/image-prompts.md +5 -0
- package/lib/drama/skills/short-drama-image-prompts/assets/lookdev-prompts.md +3 -0
- package/lib/drama/skills/short-drama-image-prompts/references/common-recipe.md +11 -0
- package/lib/drama/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +2 -2
- package/lib/drama/skills/short-drama-novel-analyze/references/chapter-extraction.md +4 -1
- package/lib/drama/skills/short-drama-produce/SKILL.md +29 -4
- package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +22 -0
- package/lib/drama/skills/short-drama-produce/references/providers/minimax-h3-video.md +13 -12
- package/lib/drama/skills/short-drama-produce/references/providers/minimax-speech.md +41 -0
- package/lib/drama/skills/short-drama-produce/references/providers/seedance.md +7 -0
- package/lib/drama/skills/short-drama-produce/scripts/fixture_adapter.py +18 -1
- package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +181 -2
- package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +199 -3
- package/lib/drama/skills/short-drama-review/SKILL.md +2 -0
- package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +7 -0
- package/lib/drama/skills/short-drama-review/references/rubric-visual-motion.md +10 -0
- package/lib/drama/skills/short-drama-storyboard/SKILL.md +12 -1
- package/lib/drama/skills/short-drama-storyboard/assets/keyframe-prompts.md +4 -0
- package/lib/drama/skills/short-drama-storyboard/references/lighting-craft.md +39 -0
- package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +78 -2
- package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +2 -0
- package/lib/drama/skills/short-drama-video-prompts/SKILL.md +12 -5
- package/lib/drama/skills/short-drama-video-prompts/references/camera-audio-continuity.md +17 -0
- package/lib/drama/skills/short-drama-video-prompts/references/minimax-h3.md +76 -7
- package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +5 -2
- package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +21 -0
- package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +6 -17
- package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.0.md +12 -3
- package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +61 -5
- package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +3 -3
- package/lib/drama/skills/short-drama-write/SKILL.md +11 -2
- package/lib/drama/skills/short-drama-write/references/screenplay-format.md +6 -0
- package/lib/drama/skills/short-drama-write/references/stage-contract.md +1 -0
- package/lib/index.js +290 -90
- package/lib/index.js.map +4 -4
- package/lib/novel-to-game/README.md +299 -59
- package/lib/novel-to-game/README_ZH.md +299 -56
- package/lib/novel-to-game/VERSION +1 -1
- package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +3 -1
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/RUN.md +11 -3
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/new-guofeng.css +397 -8
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/style.css +0 -31
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/data.js +71 -73
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/engine.js +78 -89
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/main.js +37 -14
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/text.js +5 -5
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/package.json +2 -1
- package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +11 -16
- package/lib/novel-to-game/examples/jin-ping-mei/qa/verification.json +11 -11
- package/lib/novel-to-game/examples/jin-ping-mei/qa/verify_playable_model.mjs +23 -0
- package/lib/novel-to-game/manifest.json +77 -87
- package/lib/novel-to-game/skills/game-art-direction/SKILL.md +9 -35
- package/lib/novel-to-game/skills/game-art-direction/references/art-direction-method.md +27 -106
- package/lib/novel-to-game/skills/game-build/SKILL.md +17 -35
- package/lib/novel-to-game/skills/game-build/references/blender-asset-workflow.md +24 -0
- package/lib/novel-to-game/skills/game-build/references/build-brief-contract.md +45 -82
- package/lib/novel-to-game/skills/game-build/references/generative-media-pipeline.md +14 -45
- package/lib/novel-to-game/skills/game-build/references/playable-model-contract.md +30 -65
- package/lib/novel-to-game/skills/game-build/references/tts-production-contract.md +13 -27
- package/lib/novel-to-game/skills/game-concept/SKILL.md +16 -34
- package/lib/novel-to-game/skills/game-concept/references/concept-method.md +28 -75
- package/lib/novel-to-game/skills/game-qa/SKILL.md +9 -20
- package/lib/novel-to-game/skills/game-qa/references/test-design-method.md +10 -6
- package/lib/novel-to-game/skills/game-world-design/SKILL.md +19 -32
- package/lib/novel-to-game/skills/game-world-design/references/narrative-design-method.md +23 -72
- package/lib/novel-to-game/skills/game-world-design/references/numeric-design-method.md +14 -48
- package/lib/novel-to-game/skills/game-world-design/references/world-design-method.md +36 -103
- package/lib/novel-to-game/skills/novel-game-analyze/SKILL.md +9 -18
- package/lib/novel-to-game/skills/novel-game-analyze/references/gameability-protocol.md +4 -4
- package/lib/novel-to-game/skills/novel-to-game/SKILL.md +10 -27
- package/lib/novel-to-game/skills/novel-to-game/references/intake-method.md +5 -6
- package/lib/novel-to-game/skills/novel-to-game/references/pipeline-contract.md +6 -11
- package/lib/oh-story/manifest.json +419 -254
- package/lib/oh-story/roles/chapter-extractor.md +177 -237
- package/lib/oh-story/roles/character-designer.md +1 -1
- package/lib/oh-story/roles/consistency-checker.md +4 -4
- package/lib/oh-story/roles/narrative-writer.md +56 -243
- package/lib/oh-story/roles/story-architect.md +6 -6
- package/lib/oh-story/roles/story-explorer.md +11 -11
- package/lib/oh-story/skills/story/SKILL.md +13 -7
- package/lib/oh-story/skills/story/VERSION +1 -1
- package/lib/oh-story/skills/story/references/author-memory.md +61 -29
- package/lib/oh-story/skills/story/scripts/author_memory_commit.py +934 -170
- package/lib/oh-story/skills/story-cover/SKILL.md +10 -1
- package/lib/oh-story/skills/story-deslop/SKILL.md +54 -229
- package/lib/oh-story/skills/story-deslop/references/anti-ai-writing.md +29 -56
- package/lib/oh-story/skills/story-deslop/references/author-memory.md +61 -29
- package/lib/oh-story/skills/story-deslop/references/banned-words.md +3 -1
- package/lib/oh-story/skills/story-deslop/references/deslop-gates.md +154 -0
- package/lib/oh-story/skills/story-deslop/references/style-resolution.md +27 -0
- package/lib/oh-story/skills/story-deslop/scripts/author_memory_commit.py +934 -170
- package/lib/oh-story/skills/story-deslop/scripts/check-ai-patterns.js +6 -1
- package/lib/oh-story/skills/story-deslop/scripts/check-degeneration.js +39 -20
- package/lib/oh-story/skills/story-deslop/scripts/normalize-punctuation.js +10 -5
- package/lib/oh-story/skills/story-deslop/scripts/style-whitelist.js +39 -0
- package/lib/oh-story/skills/story-import/SKILL.md +78 -82
- package/lib/oh-story/skills/story-import/references/character-state-reverse.md +1 -1
- package/lib/oh-story/skills/story-import/references/format-and-structure.md +2 -2
- package/lib/oh-story/skills/story-import/references/state-tracking.md +4 -69
- package/lib/oh-story/skills/story-import/references/structure-mapping-long.md +10 -8
- package/lib/oh-story/skills/story-import/references/tracking-initialization.md +31 -0
- package/lib/oh-story/skills/story-import/references/tracking-transaction.md +9 -34
- package/lib/oh-story/skills/story-import/scripts/check-outline-contract.js +197 -15
- package/lib/oh-story/skills/story-import/scripts/tracking_commit.py +210 -2
- package/lib/oh-story/skills/story-import/scripts/wordcount_core.py +3 -0
- package/lib/oh-story/skills/story-long-analyze/SKILL.md +91 -258
- package/lib/oh-story/skills/story-long-analyze/references/author-facing.md +245 -0
- package/lib/oh-story/skills/story-long-analyze/references/deconstruction-notes.md +2 -2
- package/lib/oh-story/skills/story-long-analyze/references/inspiration-library.md +105 -0
- package/lib/oh-story/skills/story-long-analyze/references/material-decomposition.md +113 -105
- package/lib/oh-story/skills/story-long-analyze/references/output-templates.md +169 -125
- package/lib/oh-story/skills/story-long-analyze/references/pipeline-ops.md +172 -64
- package/lib/oh-story/skills/story-long-analyze/references/semantic-acceptance-fixtures.md +84 -0
- package/lib/oh-story/skills/story-long-analyze/references/style-profile-generator.md +42 -132
- package/lib/oh-story/skills/story-long-analyze/references/style-profile-protocol.md +5 -5
- package/lib/oh-story/skills/story-long-analyze/scripts/build_chapter_index.py +466 -0
- package/lib/oh-story/skills/story-long-analyze/scripts/inspect_existing_assets.py +702 -0
- package/lib/oh-story/skills/story-long-analyze/scripts/inspiration_index.py +864 -0
- package/lib/oh-story/skills/story-long-analyze/scripts/manage_analysis_run.py +1106 -0
- package/lib/oh-story/skills/story-long-analyze/scripts/render_relation_chart.py +425 -0
- package/lib/oh-story/skills/story-long-scan/SKILL.md +4 -1
- package/lib/oh-story/skills/story-long-write/SKILL.md +52 -216
- package/lib/oh-story/skills/story-long-write/references/anti-ai-writing.md +29 -56
- package/lib/oh-story/skills/story-long-write/references/artifact-protocols.md +72 -12
- package/lib/oh-story/skills/story-long-write/references/author-memory.md +61 -29
- package/lib/oh-story/skills/story-long-write/references/banned-words.md +3 -1
- package/lib/oh-story/skills/story-long-write/references/benchmark-recall.md +20 -0
- package/lib/oh-story/skills/story-long-write/references/cross-book-recall.md +26 -0
- package/lib/oh-story/skills/story-long-write/references/dialogue-mastery.md +10 -4
- package/lib/oh-story/skills/story-long-write/references/emotional-methods.md +2 -2
- package/lib/oh-story/skills/story-long-write/references/long-chapter-quality.md +6 -15
- package/lib/oh-story/skills/story-long-write/references/long-format.md +2 -0
- package/lib/oh-story/skills/story-long-write/references/outline-structure-theory.md +2 -0
- package/lib/oh-story/skills/story-long-write/references/project-files.md +110 -0
- package/lib/oh-story/skills/story-long-write/references/reader-contract-and-progression.md +12 -4
- package/lib/oh-story/skills/story-long-write/references/reference-index.md +86 -0
- package/lib/oh-story/skills/story-long-write/references/style-combat-face.md +1 -1
- package/lib/oh-story/skills/story-long-write/references/style-craft.md +9 -29
- package/lib/oh-story/skills/story-long-write/references/style-genre-modules.md +1 -1
- package/lib/oh-story/skills/story-long-write/references/style-resolution.md +27 -0
- package/lib/oh-story/skills/story-long-write/references/tracking-initialization.md +31 -0
- package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +9 -34
- package/lib/oh-story/skills/story-long-write/references/workflow-chapter.md +88 -118
- package/lib/oh-story/skills/story-long-write/references/workflow-daily.md +55 -119
- package/lib/oh-story/skills/story-long-write/references/workflow-revision.md +15 -11
- package/lib/oh-story/skills/story-long-write/references/workflow-setup.md +73 -54
- package/lib/oh-story/skills/story-long-write/references/writer-prompt-fallback.md +22 -0
- package/lib/oh-story/skills/story-long-write/references/writing-craft.md +63 -4
- package/lib/oh-story/skills/story-long-write/scripts/author_memory_commit.py +934 -170
- package/lib/oh-story/skills/story-long-write/scripts/build_writer_prompt.py +531 -0
- package/lib/oh-story/skills/story-long-write/scripts/check-ai-patterns.js +6 -1
- package/lib/oh-story/skills/story-long-write/scripts/check-degeneration.js +39 -20
- package/lib/oh-story/skills/story-long-write/scripts/check-outline-contract.js +197 -15
- package/lib/oh-story/skills/story-long-write/scripts/check-outline-copy.js +90 -23
- package/lib/oh-story/skills/story-long-write/scripts/normalize-punctuation.js +10 -5
- package/lib/oh-story/skills/story-long-write/scripts/outline_view.py +285 -0
- package/lib/oh-story/skills/story-long-write/scripts/storyctl.py +58 -0
- package/lib/oh-story/skills/story-long-write/scripts/style-whitelist.js +39 -0
- package/lib/oh-story/skills/story-long-write/scripts/tracking_commit.py +210 -2
- package/lib/oh-story/skills/story-long-write/scripts/wordcount_core.py +3 -0
- package/lib/oh-story/skills/story-review/SKILL.md +80 -87
- package/lib/oh-story/skills/story-review/references/anti-ai-writing.md +29 -56
- package/lib/oh-story/skills/story-review/references/author-memory.md +61 -29
- package/lib/oh-story/skills/story-review/references/banned-words.md +3 -1
- package/lib/oh-story/skills/story-review/references/dialogue-mastery.md +10 -4
- package/lib/oh-story/skills/story-review/references/review-quality.md +6 -12
- package/lib/oh-story/skills/story-review/references/style-resolution.md +27 -0
- package/lib/oh-story/skills/story-review/references/tracking-initialization.md +31 -0
- package/lib/oh-story/skills/story-review/references/tracking-transaction.md +9 -34
- package/lib/oh-story/skills/story-review/scripts/author_memory_commit.py +934 -170
- package/lib/oh-story/skills/story-review/scripts/check-ai-patterns.js +6 -1
- package/lib/oh-story/skills/story-review/scripts/check-degeneration.js +39 -20
- package/lib/oh-story/skills/story-review/scripts/normalize-punctuation.js +10 -5
- package/lib/oh-story/skills/story-review/scripts/style-whitelist.js +39 -0
- package/lib/oh-story/skills/story-review/scripts/tracking_commit.py +210 -2
- package/lib/oh-story/skills/story-review/scripts/wordcount_core.py +3 -0
- package/lib/oh-story/skills/story-setup/SKILL.md +58 -43
- package/lib/oh-story/skills/story-setup/references/agent-references/anti-ai-writing.md +29 -56
- package/lib/oh-story/skills/story-setup/references/agent-references/banned-words.md +3 -1
- package/lib/oh-story/skills/story-setup/references/agent-references/deslop-gates.md +154 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/dialogue-mastery.md +10 -4
- package/lib/oh-story/skills/story-setup/references/agent-references/format-and-structure.md +2 -2
- package/lib/oh-story/skills/story-setup/references/agent-references/long-emotional-methods.md +2 -2
- package/lib/oh-story/skills/story-setup/references/agent-references/long-quality.md +1 -1
- package/lib/oh-story/skills/story-setup/references/agent-references/short-genre-formulas.md +1 -1
- package/lib/oh-story/skills/story-setup/references/agent-references/style-combat-face.md +1 -1
- package/lib/oh-story/skills/story-setup/references/agent-references/style-genre-modules.md +1 -1
- package/lib/oh-story/skills/story-setup/references/agent-references/style-resolution.md +27 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/writing-craft.md +63 -4
- package/lib/oh-story/skills/story-setup/references/diagnostics.md +38 -0
- package/lib/oh-story/skills/story-short-analyze/SKILL.md +22 -3
- package/lib/oh-story/skills/story-short-analyze/references/analysis-writing-techniques.md +1 -1
- package/lib/oh-story/skills/story-short-analyze/references/material-decomposition.md +5 -1
- package/lib/oh-story/skills/story-short-analyze/references/source-story-quality.md +2 -2
- package/lib/oh-story/skills/story-short-scan/SKILL.md +5 -1
- package/lib/oh-story/skills/story-short-write/SKILL.md +15 -324
- package/lib/oh-story/skills/story-short-write/references/author-memory.md +61 -29
- package/lib/oh-story/skills/story-short-write/references/banned-words.md +3 -1
- package/lib/oh-story/skills/story-short-write/references/benchmark-recall.md +60 -0
- package/lib/oh-story/skills/story-short-write/references/dialogue-mastery.md +10 -4
- package/lib/oh-story/skills/story-short-write/references/genre-styles//344/270/226/346/203/205/346/211/223/350/204/270.md +2 -2
- package/lib/oh-story/skills/story-short-write/references/genre-styles//345/217/214/347/224/267/344/270/273.md +1 -1
- package/lib/oh-story/skills/story-short-write/references/genre-styles//345/244/215/344/273/207/346/211/223/350/204/270.md +3 -3
- package/lib/oh-story/skills/story-short-write/references/genre-styles//345/256/205/346/226/227/345/256/253/346/226/227.md +3 -3
- package/lib/oh-story/skills/story-short-write/references/genre-styles//346/200/273/350/243/201/350/261/252/351/227/250.md +3 -3
- package/lib/oh-story/skills/story-short-write/references/genre-styles//346/202/254/347/226/221.md +5 -5
- package/lib/oh-story/skills/story-short-write/references/genre-styles//346/260/221/344/277/227/346/200/252/350/260/210.md +4 -4
- package/lib/oh-story/skills/story-short-write/references/genre-styles//347/224/234/345/256/240.md +5 -5
- package/lib/oh-story/skills/story-short-write/references/genre-styles//350/277/275/345/246/273/347/201/253/350/221/254/345/234/272.md +3 -3
- package/lib/oh-story/skills/story-short-write/references/genre-writing-formulas.md +1 -1
- package/lib/oh-story/skills/story-short-write/references/genre-writing-techniques.md +1 -1
- package/lib/oh-story/skills/story-short-write/references/reference-index.md +55 -0
- package/lib/oh-story/skills/story-short-write/references/short-craft.md +12 -14
- package/lib/oh-story/skills/story-short-write/references/short-deslop.md +10 -10
- package/lib/oh-story/skills/story-short-write/references/short-format.md +3 -1
- package/lib/oh-story/skills/story-short-write/references/short-prose-quality.md +2 -2
- package/lib/oh-story/skills/story-short-write/references/style-resolution.md +27 -0
- package/lib/oh-story/skills/story-short-write/references/workflow-design.md +78 -0
- package/lib/oh-story/skills/story-short-write/references/workflow-draft.md +126 -0
- package/lib/oh-story/skills/story-short-write/references/workflow-revision.md +32 -0
- package/lib/oh-story/skills/story-short-write/references/writing-workflow.md +5 -5
- package/lib/oh-story/skills/story-short-write/scripts/author_memory_commit.py +934 -170
- package/lib/oh-story/skills/story-short-write/scripts/check-ai-patterns.js +6 -1
- package/lib/oh-story/skills/story-short-write/scripts/check-degeneration.js +39 -20
- package/lib/oh-story/skills/story-short-write/scripts/check-delivery-contract.js +34 -5
- package/lib/oh-story/skills/story-short-write/scripts/check-outline-copy.js +90 -23
- package/lib/oh-story/skills/story-short-write/scripts/normalize-punctuation.js +10 -5
- package/lib/oh-story/skills/story-short-write/scripts/style-whitelist.js +39 -0
- package/lib/types/client/drama-production-view.d.ts.map +1 -1
- package/lib/types/client/file-activity.d.ts +19 -1
- package/lib/types/client/file-activity.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/production-prompts.d.ts +7 -0
- package/lib/types/client/production-prompts.d.ts.map +1 -1
- package/lib/types/client/production-runtime.d.ts +33 -0
- package/lib/types/client/production-runtime.d.ts.map +1 -1
- package/lib/types/drama-adapters.d.ts +5 -3
- package/lib/types/drama-adapters.d.ts.map +1 -1
- package/lib/types/native-hooks.d.ts +52 -0
- package/lib/types/native-hooks.d.ts.map +1 -1
- package/lib/types/role-identity.d.ts +17 -0
- package/lib/types/role-identity.d.ts.map +1 -0
- package/lib/types/role-tool.d.ts.map +1 -1
- package/lib/types/skill-provider.d.ts.map +1 -1
- package/lib/video-recap/CHANGELOG.md +31 -1
- package/lib/video-recap/manifest.json +162 -162
- package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +4 -9
- package/lib/video-recap/skills/video-assemble/scripts/assemble.py +47 -60
- package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +77 -115
- package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +41 -40
- package/lib/video-recap/skills/video-assemble/scripts/audio_automation.py +22 -32
- package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +102 -192
- package/lib/video-recap/skills/video-assemble/scripts/jianying_builders.py +14 -35
- package/lib/video-recap/skills/video-assemble/scripts/jianying_model.py +14 -12
- package/lib/video-recap/skills/video-assemble/scripts/jianying_optional.py +8 -9
- package/lib/video-recap/skills/video-assemble/scripts/jianying_schema.py +3 -14
- package/lib/video-recap/skills/video-assemble/scripts/jianying_timeline_contract.py +23 -12
- package/lib/video-recap/skills/video-assemble/scripts/jianying_tracks.py +1 -4
- package/lib/video-recap/skills/video-assemble/scripts/jianying_writer.py +24 -25
- package/lib/video-recap/skills/video-assemble/scripts/lib.py +31 -85
- package/lib/video-recap/skills/video-assemble/scripts/media.py +100 -144
- package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +101 -195
- package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +10 -21
- package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +90 -169
- package/lib/video-recap/skills/video-assemble/scripts/subtitle_core.py +69 -122
- package/lib/video-recap/skills/video-assemble/scripts/subtitle_render.py +10 -17
- package/lib/video-recap/skills/video-assemble/scripts/timeline.py +33 -48
- package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +14 -20
- package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +135 -213
- package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +49 -57
- package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +38 -74
- package/lib/video-recap/skills/video-cut/scripts/cut_render.py +46 -95
- package/lib/video-recap/skills/video-cut/scripts/lib.py +23 -25
- package/lib/video-recap/skills/video-cut/scripts/media_geometry.py +70 -173
- package/lib/video-recap/skills/video-cut/scripts/narration_mapping.py +28 -53
- package/lib/video-recap/skills/video-cut/scripts/sentence_boundaries.py +162 -322
- package/lib/video-recap/skills/video-recap/references/timeline-and-jianying.md +6 -7
- package/lib/video-recap/skills/video-recap/scripts/doctor.py +104 -124
- package/lib/video-recap/skills/video-recap/scripts/final_qc.py +255 -412
- package/lib/video-recap/skills/video-recap/scripts/lib.py +30 -52
- package/lib/video-recap/skills/video-recap/scripts/materials.py +97 -120
- package/lib/video-recap/skills/video-recap/scripts/mimo_qc_client.py +7 -12
- package/lib/video-recap/skills/video-recap/scripts/mimo_qc_evidence.py +46 -97
- package/lib/video-recap/skills/video-recap/scripts/mimo_qc_observations.py +18 -36
- package/lib/video-recap/skills/video-recap/scripts/mimo_qc_payload.py +29 -47
- package/lib/video-recap/skills/video-recap/scripts/mimo_qc_report.py +43 -76
- package/lib/video-recap/skills/video-recap/scripts/mimo_qc_runner.py +2 -1
- package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +2 -1
- package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +2 -2
- package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +129 -145
- package/lib/video-recap/skills/video-recap/scripts/recap_review.py +27 -20
- package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +20 -31
- package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +31 -106
- package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +43 -113
- package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +196 -377
- package/lib/video-recap/skills/video-script/scripts/agent_brief.py +1 -1
- package/lib/video-recap/skills/video-script/scripts/agent_text.py +63 -172
- package/lib/video-recap/skills/video-script/scripts/brief_context.py +80 -135
- package/lib/video-recap/skills/video-script/scripts/brief_inputs.py +125 -167
- package/lib/video-recap/skills/video-script/scripts/brief_timeline.py +93 -208
- package/lib/video-recap/skills/video-script/scripts/deslop_qc.py +20 -46
- package/lib/video-recap/skills/video-script/scripts/lib.py +30 -26
- package/lib/video-recap/skills/video-script/scripts/narration_lint.py +145 -140
- package/lib/video-recap/skills/video-script/scripts/review_response.py +12 -42
- package/lib/video-recap/skills/video-script/scripts/review_runner.py +0 -6
- package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +39 -100
- package/lib/video-recap/skills/video-script/scripts/timeline_fusion.py +33 -124
- package/lib/video-recap/skills/video-understanding/scripts/agent_brief.py +1 -1
- package/lib/video-recap/skills/video-understanding/scripts/agent_text.py +63 -172
- package/lib/video-recap/skills/video-understanding/scripts/asr.py +1 -2
- package/lib/video-recap/skills/video-understanding/scripts/brief_context.py +80 -135
- package/lib/video-recap/skills/video-understanding/scripts/brief_inputs.py +125 -167
- package/lib/video-recap/skills/video-understanding/scripts/brief_timeline.py +93 -208
- package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +1 -1
- package/lib/video-recap/skills/video-understanding/scripts/deslop_qc.py +20 -46
- package/lib/video-recap/skills/video-understanding/scripts/lib.py +31 -26
- package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +145 -140
- package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +39 -100
- package/lib/video-recap/skills/video-understanding/scripts/timeline_fusion.py +33 -124
- package/lib/video-recap/skills/video-understanding/scripts/vlm.py +1 -4
- package/lib/video-recap/skills/video-voiceover/scripts/dub.py +7 -13
- package/lib/video-recap/skills/video-voiceover/scripts/fish_audio.py +12 -30
- package/lib/video-recap/skills/video-voiceover/scripts/lib.py +98 -131
- package/lib/video-recap/skills/video-voiceover/scripts/tts_audio.py +17 -28
- package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +151 -240
- package/package.json +45 -45
- package/lib/novel-to-game/skills/game-world-design/references/dialogue-design-method.md +0 -59
- package/lib/novel-to-game/skills/game-world-design/references/game-writing-craft.md +0 -53
- package/lib/novel-to-game/skills/novel-to-game/references/intake-benchmark-reference.md +0 -42
- package/lib/oh-story/skills/story-long-write/references/state-tracking.md +0 -108
|
@@ -0,0 +1,1106 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Plan, commit, split, and recover long-analysis batches.
|
|
3
|
+
|
|
4
|
+
Runtime state lives only in the managed block of ``_progress.md``. Plans are
|
|
5
|
+
printed as JSON and are never persisted. Batch caches are complete recovery
|
|
6
|
+
evidence, not a second state database. An existing chapter summary is never
|
|
7
|
+
overwritten: to redo a chapter, delete its summary and plan again.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import codecs
|
|
14
|
+
import csv
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
|
|
23
|
+
|
|
24
|
+
from inspect_existing_assets import inspect
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
STATE_START = "<!-- story-long-analyze:runtime-state:start -->"
|
|
28
|
+
STATE_END = "<!-- story-long-analyze:runtime-state:end -->"
|
|
29
|
+
CACHE_START = "<!-- story-long-analyze:cache:start -->"
|
|
30
|
+
CACHE_END = "<!-- story-long-analyze:cache:end -->"
|
|
31
|
+
MODEL_START = "<!-- MODEL_OUTPUT_START -->"
|
|
32
|
+
MODEL_END = "<!-- MODEL_OUTPUT_END -->"
|
|
33
|
+
CHAPTER_TOKEN_RE = re.compile(r"<!--\s*CHAPTER_(START|END):(\d+)\s*-->")
|
|
34
|
+
CHAPTER_BLOCK_RE = re.compile(
|
|
35
|
+
r"<!--\s*CHAPTER_START:(\d+)\s*-->\s*(.*?)\s*<!--\s*CHAPTER_END:\1\s*-->", re.DOTALL
|
|
36
|
+
)
|
|
37
|
+
BATCH_ID_RE = re.compile(r"^(RAW|REUSE)-(\d+)-(\d+)$")
|
|
38
|
+
COMPACT_FIELDS = (
|
|
39
|
+
"概要", "因果", "关键行动", "局面结果", "涉及人物", "信息变化", "状态变化",
|
|
40
|
+
"三维节奏", "章尾钩子", "证据",
|
|
41
|
+
)
|
|
42
|
+
POINT_HEADER_RE = re.compile(r"^P(\d+)\s+\*\*(.+?)\*\*\s*[::]\s*(.+)$")
|
|
43
|
+
POINT_TAG_RE = re.compile(r"^主题标签\s*[::]?\s*([^||]*?)\s*[||]\s*基调\s*[::]?\s*(.*?)\s*$")
|
|
44
|
+
THEMES = ("爱情", "亲情", "友情", "权力", "金钱", "成长", "复仇", "悬念", "搞笑", "热血", "日常", "其他")
|
|
45
|
+
TONES = ("紧张", "轻松", "悲伤", "热血", "爽", "甜", "温馨", "恐怖", "压抑", "其他")
|
|
46
|
+
POINT_TYPES = ("转折点", "信息揭示", "冲突", "解决", "铺垫", "行动", "对话", "状态变化")
|
|
47
|
+
THEME_ALIASES = {"恋爱": "爱情", "权谋": "权力", "政治": "权力", "幽默": "搞笑"}
|
|
48
|
+
TONE_ALIASES = {"悲痛": "悲伤", "伤感": "悲伤", "悲愤": "悲伤", "痛快": "爽", "解气": "爽", "惊悚": "恐怖",
|
|
49
|
+
"恐惧": "恐怖", "危险": "紧张", "危急": "紧张", "绝望": "压抑", "无力": "压抑", "释然": "轻松"}
|
|
50
|
+
POINT_ALIASES = {"揭示": "信息揭示", "转折": "转折点", "变化": "状态变化", "动作": "行动",
|
|
51
|
+
"交谈": "对话", "化解": "解决"}
|
|
52
|
+
MIN_PLOT_POINTS = 10
|
|
53
|
+
MAX_PLOT_POINTS = 30
|
|
54
|
+
MAX_CHAPTERS = 3
|
|
55
|
+
MAX_CHARS = 25_000
|
|
56
|
+
# Stage 3-6 each run once over complete Stage 2 output; their rows decide 最终状态.
|
|
57
|
+
FINAL_STAGES = ("stage3", "stage4", "stage5", "stage6")
|
|
58
|
+
LEGACY_FINAL_RE = re.compile(r"(?m)^([ \t]*(?:[-*][ \t]*)?最终状态[ \t]*[::][ \t]*)([A-Za-z0-9_]+)")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RunError(ValueError):
|
|
62
|
+
def __init__(self, code: str, detail: str, author_message: Optional[str] = None) -> None:
|
|
63
|
+
super().__init__(detail)
|
|
64
|
+
self.code = code
|
|
65
|
+
self.detail = detail
|
|
66
|
+
self.author_message = author_message
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def sha256(data: bytes) -> str:
|
|
70
|
+
return hashlib.sha256(data).hexdigest()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def normalized(text: str) -> str:
|
|
74
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def atomic_write(path: Path, data: bytes) -> None:
|
|
78
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
fd, temporary = tempfile.mkstemp(prefix=".%s." % path.name, suffix=".tmp", dir=str(path.parent))
|
|
80
|
+
try:
|
|
81
|
+
with os.fdopen(fd, "wb") as handle:
|
|
82
|
+
handle.write(data)
|
|
83
|
+
handle.flush()
|
|
84
|
+
os.fsync(handle.fileno())
|
|
85
|
+
os.replace(temporary, str(path))
|
|
86
|
+
except BaseException:
|
|
87
|
+
try:
|
|
88
|
+
os.unlink(temporary)
|
|
89
|
+
except OSError:
|
|
90
|
+
pass
|
|
91
|
+
raise
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def require_root(root: Path) -> Path:
|
|
95
|
+
root = root.resolve()
|
|
96
|
+
if not root.exists():
|
|
97
|
+
raise RunError("root_not_found", str(root))
|
|
98
|
+
if not root.is_dir():
|
|
99
|
+
raise RunError("root_is_not_directory", str(root))
|
|
100
|
+
try:
|
|
101
|
+
next(root.iterdir(), None)
|
|
102
|
+
except OSError as exc:
|
|
103
|
+
raise RunError("root_unreadable", str(exc)) from exc
|
|
104
|
+
return root
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def read_index(root: Path, index_arg: Optional[Path] = None) -> List[Dict[str, Any]]:
|
|
108
|
+
path = (index_arg.resolve() if index_arg else root / "chapter_index.csv")
|
|
109
|
+
if not path.is_file():
|
|
110
|
+
raise RunError("chapter_index_required", str(path))
|
|
111
|
+
rows = [] # type: List[Dict[str, Any]]
|
|
112
|
+
try:
|
|
113
|
+
with path.open("r", encoding="utf-8-sig", newline="") as handle:
|
|
114
|
+
reader = csv.DictReader(handle)
|
|
115
|
+
required = {"chapter", "start_line", "end_line", "char_count", "source_locator", "chapter_sha256"}
|
|
116
|
+
missing = required - set(reader.fieldnames or ())
|
|
117
|
+
if missing:
|
|
118
|
+
raise RunError("chapter_index_invalid", "missing columns: %s" % ", ".join(sorted(missing)))
|
|
119
|
+
for raw in reader:
|
|
120
|
+
row = dict(raw)
|
|
121
|
+
try:
|
|
122
|
+
row["chapter"] = int(row["chapter"])
|
|
123
|
+
row["start_line"] = int(row["start_line"])
|
|
124
|
+
row["end_line"] = int(row["end_line"])
|
|
125
|
+
row["char_count"] = int(row["char_count"])
|
|
126
|
+
except (TypeError, ValueError) as exc:
|
|
127
|
+
raise RunError("chapter_index_invalid", "numeric column invalid") from exc
|
|
128
|
+
if not re.fullmatch(r"[0-9a-f]{64}", str(row["chapter_sha256"])):
|
|
129
|
+
raise RunError("chapter_index_invalid", "chapter_sha256 invalid")
|
|
130
|
+
rows.append(row)
|
|
131
|
+
except (OSError, UnicodeError, csv.Error) as exc:
|
|
132
|
+
raise RunError("chapter_index_unreadable", str(exc)) from exc
|
|
133
|
+
if [row["chapter"] for row in rows] != list(range(1, len(rows) + 1)):
|
|
134
|
+
raise RunError("chapter_index_invalid", "chapter ids must be continuous from 1")
|
|
135
|
+
return rows
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def range_sha256(rows: Sequence[Dict[str, Any]], start: int, end: int) -> str:
|
|
139
|
+
selected = [row for row in rows if start <= row["chapter"] <= end]
|
|
140
|
+
if [row["chapter"] for row in selected] != list(range(start, end + 1)):
|
|
141
|
+
raise RunError("range_not_in_index", "%s-%s" % (start, end))
|
|
142
|
+
payload = "range-v1\n" + "".join(
|
|
143
|
+
"%s:%s\n" % (row["chapter"], row["chapter_sha256"]) for row in selected
|
|
144
|
+
)
|
|
145
|
+
return sha256(payload.encode("ascii"))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def decode_progress(raw: bytes) -> Tuple[str, bool, str]:
|
|
149
|
+
bom = raw.startswith(codecs.BOM_UTF8)
|
|
150
|
+
text = raw.decode("utf-8-sig")
|
|
151
|
+
newline = "\r\n" if "\r\n" in text else "\n"
|
|
152
|
+
return text, bom, newline
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def empty_state() -> Dict[str, Any]:
|
|
156
|
+
return {"batches": {}, "stages": {}}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def table_cells(line: str) -> List[str]:
|
|
160
|
+
return [cell.strip() for cell in line.strip().strip("|").split("|")]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def load_state(progress: Path) -> Tuple[Dict[str, Any], bytes]:
|
|
164
|
+
raw = progress.read_bytes() if progress.is_file() else b""
|
|
165
|
+
text, _, _ = decode_progress(raw)
|
|
166
|
+
state = empty_state()
|
|
167
|
+
start_count = text.count(STATE_START)
|
|
168
|
+
end_count = text.count(STATE_END)
|
|
169
|
+
if start_count != end_count or start_count > 1:
|
|
170
|
+
raise RunError(
|
|
171
|
+
"progress_state_block_invalid",
|
|
172
|
+
"expected zero or one complete managed state block; found start=%s end=%s"
|
|
173
|
+
% (start_count, end_count),
|
|
174
|
+
)
|
|
175
|
+
match = re.search(re.escape(STATE_START) + r"(.*?)" + re.escape(STATE_END), text, re.DOTALL)
|
|
176
|
+
if not match:
|
|
177
|
+
return state, raw
|
|
178
|
+
section = None
|
|
179
|
+
for line in match.group(1).splitlines():
|
|
180
|
+
if line.strip() == "### 批次状态":
|
|
181
|
+
section = "batches"
|
|
182
|
+
continue
|
|
183
|
+
if line.strip() == "### 阶段状态":
|
|
184
|
+
section = "stages"
|
|
185
|
+
continue
|
|
186
|
+
if not line.lstrip().startswith("|") or set(line.replace("|", "").replace("-", "").replace(":", "").strip()) == set():
|
|
187
|
+
continue
|
|
188
|
+
cells = table_cells(line)
|
|
189
|
+
if section == "batches" and cells and cells[0] not in {"批次ID", "---"} and len(cells) >= 7:
|
|
190
|
+
try:
|
|
191
|
+
start, end = [int(value) for value in cells[1].split("-", 1)]
|
|
192
|
+
except (ValueError, IndexError):
|
|
193
|
+
continue
|
|
194
|
+
state["batches"][cells[0]] = {
|
|
195
|
+
"batch_id": cells[0], "start": start, "end": end, "input_kind": cells[2],
|
|
196
|
+
"range_sha256": cells[3], "status": cells[4],
|
|
197
|
+
"parent": "" if cells[5] == "-" else cells[5],
|
|
198
|
+
"cache": "" if cells[6] == "-" else cells[6],
|
|
199
|
+
}
|
|
200
|
+
elif section == "stages" and cells and cells[0] not in {"阶段", "---"} and len(cells) >= 3:
|
|
201
|
+
state["stages"][cells[0]] = {"status": cells[1], "output": "" if cells[2] == "-" else cells[2]}
|
|
202
|
+
return state, raw
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def final_status(stages: Dict[str, Dict[str, str]]) -> str:
|
|
206
|
+
"""Value for the ``最终状态`` line that session hooks read (Stage 3-6 rows)."""
|
|
207
|
+
statuses = [stages.get(stage, {}).get("status") for stage in FINAL_STAGES]
|
|
208
|
+
if all(status in {"completed", "completed_with_errors"} for status in statuses):
|
|
209
|
+
return "completed_with_errors" if "completed_with_errors" in statuses else "completed"
|
|
210
|
+
return "pending"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def render_state(state: Dict[str, Any], newline: str, final_line: bool = True) -> str:
|
|
214
|
+
lines = [STATE_START, "## 长篇拆文运行状态", ""]
|
|
215
|
+
if final_line and state["stages"]:
|
|
216
|
+
lines.extend(["- 最终状态:%s" % final_status(state["stages"]), ""])
|
|
217
|
+
lines.extend(["### 批次状态",
|
|
218
|
+
"| 批次ID | 章节范围 | 输入 | 原文范围hash | 状态 | 父批次 | 缓存 |",
|
|
219
|
+
"|---|---|---|---|---|---|---|"])
|
|
220
|
+
batches = list(state["batches"].values())
|
|
221
|
+
batches.sort(key=lambda row: (row["start"], row["end"], row["batch_id"]))
|
|
222
|
+
for row in batches:
|
|
223
|
+
lines.append("| %s | %s-%s | %s | %s | %s | %s | %s |" % (
|
|
224
|
+
row["batch_id"], row["start"], row["end"], row["input_kind"],
|
|
225
|
+
row["range_sha256"], row["status"], row.get("parent") or "-", row.get("cache") or "-"))
|
|
226
|
+
lines.extend(["", "### 阶段状态", "| 阶段 | 状态 | 产物 |", "|---|---|---|"])
|
|
227
|
+
for stage in sorted(state["stages"]):
|
|
228
|
+
row = state["stages"][stage]
|
|
229
|
+
lines.append("| %s | %s | %s |" % (stage, row["status"], row.get("output") or "-"))
|
|
230
|
+
lines.append(STATE_END)
|
|
231
|
+
return newline.join(lines)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def write_state(progress: Path, state: Dict[str, Any]) -> bool:
|
|
235
|
+
raw = progress.read_bytes() if progress.is_file() else b""
|
|
236
|
+
text, bom, newline = decode_progress(raw)
|
|
237
|
+
pattern = re.compile(re.escape(STATE_START) + r".*?" + re.escape(STATE_END), re.DOTALL)
|
|
238
|
+
# Hooks read the first 最终状态 in the file. A legacy project keeps its own
|
|
239
|
+
# line as that single value; the runtime only promotes it once Stage 3-6
|
|
240
|
+
# are all complete and never demotes it.
|
|
241
|
+
existing = pattern.search(text)
|
|
242
|
+
head = text[:existing.start()] if existing else text
|
|
243
|
+
legacy = LEGACY_FINAL_RE.search(head)
|
|
244
|
+
status = final_status(state["stages"])
|
|
245
|
+
if legacy and status != "pending" and legacy.group(2) != status:
|
|
246
|
+
head = head[:legacy.start(2)] + status + head[legacy.end(2):]
|
|
247
|
+
text = head + (text[existing.start():] if existing else "")
|
|
248
|
+
block = render_state(state, newline, final_line=legacy is None)
|
|
249
|
+
if pattern.search(text):
|
|
250
|
+
updated = pattern.sub(lambda _: block, text, count=1)
|
|
251
|
+
else:
|
|
252
|
+
if not text:
|
|
253
|
+
text = "# 深度拆解进度" + newline + "- schema_version: 2" + newline
|
|
254
|
+
separator = "" if text.endswith(newline + newline) else (newline if text.endswith(newline) else newline + newline)
|
|
255
|
+
updated = text + separator + block + newline
|
|
256
|
+
data = ((codecs.BOM_UTF8 if bom else b"") + updated.encode("utf-8"))
|
|
257
|
+
if data == raw:
|
|
258
|
+
return False
|
|
259
|
+
atomic_write(progress, data)
|
|
260
|
+
return True
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def summary_path(root: Path, chapter: int) -> Path:
|
|
264
|
+
return root / "章节" / ("第%s章_摘要.md" % chapter)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def cache_complete(path: Path) -> bool:
|
|
268
|
+
try:
|
|
269
|
+
text = normalized(path.read_text(encoding="utf-8-sig"))
|
|
270
|
+
except (OSError, UnicodeError):
|
|
271
|
+
return False
|
|
272
|
+
return text.rstrip().endswith(CACHE_END) and text.count(MODEL_START) == 1 and text.count(MODEL_END) == 1
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def cache_path(root: Path, batch_id: str) -> Path:
|
|
276
|
+
return root / "_analysis_cache" / ("批次-%s.md" % batch_id)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def completed_batch(root: Path, row: Dict[str, Any], rows: Optional[Sequence[Dict[str, Any]]]) -> bool:
|
|
280
|
+
if row.get("status") not in {"completed", "success"} or not all(summary_path(root, chapter).is_file() for chapter in range(row["start"], row["end"] + 1)):
|
|
281
|
+
return False
|
|
282
|
+
path = root / row.get("cache", "") if row.get("cache") else cache_path(root, row["batch_id"])
|
|
283
|
+
if not cache_complete(path):
|
|
284
|
+
return False
|
|
285
|
+
try:
|
|
286
|
+
metadata = parse_cache(path)
|
|
287
|
+
except (OSError, UnicodeError, RunError):
|
|
288
|
+
return False
|
|
289
|
+
if (metadata.get("batch_id"), metadata.get("start"), metadata.get("end")) != (
|
|
290
|
+
row["batch_id"], row["start"], row["end"]
|
|
291
|
+
):
|
|
292
|
+
return False
|
|
293
|
+
if metadata.get("range_sha256") != row.get("range_sha256"):
|
|
294
|
+
return False
|
|
295
|
+
if row["input_kind"] == "raw-original":
|
|
296
|
+
return rows is not None and row.get("range_sha256") == range_sha256(rows, row["start"], row["end"])
|
|
297
|
+
return True
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def compact_ranges(chapters: Iterable[int]) -> List[Tuple[int, int]]:
|
|
301
|
+
values = sorted(set(chapters))
|
|
302
|
+
if not values:
|
|
303
|
+
return []
|
|
304
|
+
result = []
|
|
305
|
+
start = previous = values[0]
|
|
306
|
+
for value in values[1:]:
|
|
307
|
+
if value == previous + 1:
|
|
308
|
+
previous = value
|
|
309
|
+
else:
|
|
310
|
+
result.append((start, previous))
|
|
311
|
+
start = previous = value
|
|
312
|
+
result.append((start, previous))
|
|
313
|
+
return result
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def chunk_range(start: int, end: int, index_by_chapter: Optional[Dict[int, Dict[str, Any]]]) -> List[Tuple[int, int]]:
|
|
317
|
+
result = []
|
|
318
|
+
current_start = start
|
|
319
|
+
count = 0
|
|
320
|
+
chars = 0
|
|
321
|
+
previous = start - 1
|
|
322
|
+
for chapter in range(start, end + 1):
|
|
323
|
+
chapter_chars = index_by_chapter[chapter]["char_count"] if index_by_chapter else 0
|
|
324
|
+
if count and (count + 1 > MAX_CHAPTERS or chars + chapter_chars > MAX_CHARS):
|
|
325
|
+
result.append((current_start, previous))
|
|
326
|
+
current_start = chapter
|
|
327
|
+
count = 0
|
|
328
|
+
chars = 0
|
|
329
|
+
count += 1
|
|
330
|
+
chars += chapter_chars
|
|
331
|
+
previous = chapter
|
|
332
|
+
result.append((current_start, previous))
|
|
333
|
+
return result
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def invalid_batch_targets(root: Path, rows: Optional[Sequence[Dict[str, Any]]],
|
|
337
|
+
state: Dict[str, Any]) -> Tuple[Set[int], Set[int]]:
|
|
338
|
+
"""Chapters of recorded batches that no longer verify (cache lost or source changed)."""
|
|
339
|
+
raw = set()
|
|
340
|
+
reuse = set()
|
|
341
|
+
for batch in state["batches"].values():
|
|
342
|
+
chapters = set(range(batch["start"], batch["end"] + 1))
|
|
343
|
+
# Split children stay owed until committed, even when summaries exist.
|
|
344
|
+
pending_child = batch.get("parent") and batch.get("status") == "planned"
|
|
345
|
+
if not pending_child and (
|
|
346
|
+
batch.get("status") not in {"completed", "success"} or completed_batch(root, batch, rows)
|
|
347
|
+
):
|
|
348
|
+
continue
|
|
349
|
+
if batch.get("input_kind") == "raw-original":
|
|
350
|
+
raw.update(chapters)
|
|
351
|
+
elif batch.get("input_kind") == "existing-results":
|
|
352
|
+
reuse.update(chapters)
|
|
353
|
+
return raw, reuse
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def add_recoverable_caches(root: Path, state: Dict[str, Any],
|
|
357
|
+
rows: Optional[Sequence[Dict[str, Any]]]) -> Tuple[List[Dict[str, Any]], Set[int]]:
|
|
358
|
+
"""Use complete caches in this read-only plan without persisting recovery."""
|
|
359
|
+
cache_dir = root / "_analysis_cache"
|
|
360
|
+
recoverable = []
|
|
361
|
+
covered = set() # type: Set[int]
|
|
362
|
+
for path in sorted(cache_dir.glob("批次-*.md")) if cache_dir.is_dir() else []:
|
|
363
|
+
try:
|
|
364
|
+
metadata = parse_cache(path)
|
|
365
|
+
batch_id = metadata["batch_id"]
|
|
366
|
+
input_kind, start, end = parse_batch_id(batch_id)
|
|
367
|
+
if metadata["input_kind"] != input_kind:
|
|
368
|
+
continue
|
|
369
|
+
current_hash = metadata["range_sha256"]
|
|
370
|
+
if input_kind == "raw-original":
|
|
371
|
+
if rows is None or current_hash != range_sha256(rows, start, end):
|
|
372
|
+
continue
|
|
373
|
+
records = parse_model_output(metadata["model_output"], start, end, input_kind)
|
|
374
|
+
missing = [chapter for chapter in range(start, end + 1) if not summary_path(root, chapter).is_file()]
|
|
375
|
+
if missing and not all(chapter in records for chapter in missing):
|
|
376
|
+
continue
|
|
377
|
+
covered.update(range(start, end + 1))
|
|
378
|
+
current = state["batches"].get(batch_id)
|
|
379
|
+
needs_state_repair = current is None or not completed_batch(root, current, rows)
|
|
380
|
+
if missing or needs_state_repair:
|
|
381
|
+
recoverable.append({"batch_id": batch_id, "cache": path.relative_to(root).as_posix(),
|
|
382
|
+
"missing_summary_chapters": missing,
|
|
383
|
+
"state_repair_required": needs_state_repair})
|
|
384
|
+
if missing:
|
|
385
|
+
continue
|
|
386
|
+
recovered = {
|
|
387
|
+
"batch_id": batch_id, "start": start, "end": end, "input_kind": input_kind,
|
|
388
|
+
"range_sha256": current_hash, "status": "completed", "parent": "",
|
|
389
|
+
"cache": path.relative_to(root).as_posix(),
|
|
390
|
+
}
|
|
391
|
+
if current is None or not completed_batch(root, current, rows):
|
|
392
|
+
state["batches"][batch_id] = recovered
|
|
393
|
+
except (OSError, UnicodeError, RunError, ValueError):
|
|
394
|
+
continue
|
|
395
|
+
return recoverable, covered
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def plan_command(args: argparse.Namespace) -> Dict[str, Any]:
|
|
399
|
+
root = require_root(args.root)
|
|
400
|
+
report = inspect(root, args.expected_chapters)
|
|
401
|
+
state, _ = load_state(root / "_progress.md")
|
|
402
|
+
index_rows = None # type: Optional[List[Dict[str, Any]]]
|
|
403
|
+
index_path = args.index.resolve() if args.index else root / "chapter_index.csv"
|
|
404
|
+
if index_path.is_file():
|
|
405
|
+
index_rows = read_index(root, index_path)
|
|
406
|
+
recoverable_caches, cache_covered = add_recoverable_caches(root, state, index_rows)
|
|
407
|
+
expected = report.get("expected_chapters") or (len(index_rows) if index_rows else None)
|
|
408
|
+
if not expected:
|
|
409
|
+
raise RunError("expected_chapters_unknown", "build the chapter index or pass --expected-chapters")
|
|
410
|
+
semantic = set(report["completed_semantic_chapters"])
|
|
411
|
+
summaries = set(report["completed_summary_chapters"])
|
|
412
|
+
all_chapters = set(range(1, int(expected) + 1))
|
|
413
|
+
invalid_raw, invalid_reuse = invalid_batch_targets(root, index_rows, state)
|
|
414
|
+
|
|
415
|
+
raw_targets = (all_chapters - semantic) | invalid_raw
|
|
416
|
+
if args.intent == "enhance":
|
|
417
|
+
reuse_targets = (semantic - raw_targets) | invalid_reuse
|
|
418
|
+
else:
|
|
419
|
+
reuse_targets = ((semantic - summaries) | invalid_reuse) - raw_targets
|
|
420
|
+
raw_targets -= cache_covered
|
|
421
|
+
reuse_targets -= cache_covered
|
|
422
|
+
if raw_targets and index_rows is None:
|
|
423
|
+
raise RunError("chapter_index_required", "raw-original work remains")
|
|
424
|
+
if index_rows is not None:
|
|
425
|
+
absent = raw_targets - {row["chapter"] for row in index_rows}
|
|
426
|
+
if absent:
|
|
427
|
+
raise RunError("range_not_in_index", ",".join(map(str, sorted(absent))))
|
|
428
|
+
mapping_blocked = set(report.get("chapter_mapping_blocked_chapters", []))
|
|
429
|
+
if mapping_blocked & (raw_targets | reuse_targets):
|
|
430
|
+
raise RunError(
|
|
431
|
+
"chapter_mapping_ambiguous",
|
|
432
|
+
"; ".join(report.get("chapter_mapping_conflicts", [])) or "legacy chapter identity is unresolved",
|
|
433
|
+
report.get("chapter_mapping_author_message"),
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
selected = [] # type: List[Dict[str, Any]]
|
|
437
|
+
index_by_chapter = {row["chapter"]: row for row in index_rows or []}
|
|
438
|
+
preferred_paths = report["chapter_sources"]["preferred_paths"]
|
|
439
|
+
for kind, targets in (("raw-original", raw_targets), ("existing-results", reuse_targets)):
|
|
440
|
+
for range_start, range_end in compact_ranges(targets):
|
|
441
|
+
for start, end in chunk_range(range_start, range_end, index_by_chapter if kind == "raw-original" else None):
|
|
442
|
+
selected.append({"input_kind": kind, "start": start, "end": end})
|
|
443
|
+
|
|
444
|
+
# Persisted split children replace any recombined parent range on later plans.
|
|
445
|
+
split_children = [
|
|
446
|
+
row for row in state["batches"].values()
|
|
447
|
+
if row.get("parent") and row.get("status") != "superseded"
|
|
448
|
+
]
|
|
449
|
+
for child in split_children:
|
|
450
|
+
for item in list(selected):
|
|
451
|
+
if item["input_kind"] == child["input_kind"] and item["start"] <= child["start"] and child["end"] <= item["end"]:
|
|
452
|
+
selected.remove(item)
|
|
453
|
+
if item["start"] < child["start"]:
|
|
454
|
+
selected.append({"input_kind": item["input_kind"], "start": item["start"], "end": child["start"] - 1})
|
|
455
|
+
selected.append({"input_kind": child["input_kind"], "start": child["start"], "end": child["end"]})
|
|
456
|
+
if child["end"] < item["end"]:
|
|
457
|
+
selected.append({"input_kind": item["input_kind"], "start": child["end"] + 1, "end": item["end"]})
|
|
458
|
+
break
|
|
459
|
+
|
|
460
|
+
batches = []
|
|
461
|
+
raw_reads = 0
|
|
462
|
+
result_reads = 0
|
|
463
|
+
for item in sorted(selected, key=lambda value: (value["start"], value["end"], value["input_kind"])):
|
|
464
|
+
prefix = "RAW" if item["input_kind"] == "raw-original" else "REUSE"
|
|
465
|
+
batch_id = "%s-%s-%s" % (prefix, item["start"], item["end"])
|
|
466
|
+
current_range_hash = range_sha256(index_rows, item["start"], item["end"]) if item["input_kind"] == "raw-original" else "existing-results"
|
|
467
|
+
prior = state["batches"].get(batch_id)
|
|
468
|
+
if prior and completed_batch(root, prior, index_rows):
|
|
469
|
+
continue
|
|
470
|
+
if item["input_kind"] == "raw-original":
|
|
471
|
+
sources = [index_by_chapter[chapter]["source_locator"] for chapter in range(item["start"], item["end"] + 1)]
|
|
472
|
+
raw_reads += len(sources)
|
|
473
|
+
else:
|
|
474
|
+
sources = sorted(set(preferred_paths.get(str(chapter), "") for chapter in range(item["start"], item["end"] + 1)) - {""})
|
|
475
|
+
result_reads += len(sources)
|
|
476
|
+
batches.append({
|
|
477
|
+
"batch_id": batch_id, "chapter_range": [item["start"], item["end"]],
|
|
478
|
+
"input_kind": item["input_kind"], "range_sha256": current_range_hash,
|
|
479
|
+
"source_files": sources, "cache": "_analysis_cache/批次-%s.md" % batch_id,
|
|
480
|
+
"chapter_chars": [index_by_chapter[chapter]["char_count"] for chapter in range(item["start"], item["end"] + 1)]
|
|
481
|
+
if item["input_kind"] == "raw-original" else [],
|
|
482
|
+
})
|
|
483
|
+
required_stages = list(report.get("stage_repairs", []))
|
|
484
|
+
if batches or recoverable_caches:
|
|
485
|
+
required_stages = sorted(set(required_stages + ["stage2"]))
|
|
486
|
+
summary_gaps = sorted(all_chapters - summaries)
|
|
487
|
+
payload = {
|
|
488
|
+
"ok": True, "root": str(root), "intent": args.intent,
|
|
489
|
+
"classification": report["classification"], "recommended_path": report["recommended_path"],
|
|
490
|
+
"mixed_sources": report["mixed_sources"], "batches": batches,
|
|
491
|
+
"remaining_batches": len(batches),
|
|
492
|
+
"recoverable_caches": recoverable_caches,
|
|
493
|
+
"summary_gaps": summary_gaps,
|
|
494
|
+
"read_counts": {"raw_chapters": raw_reads, "existing_result_files": result_reads},
|
|
495
|
+
"required_stages": required_stages,
|
|
496
|
+
"state_written": False,
|
|
497
|
+
}
|
|
498
|
+
if args.next is not None:
|
|
499
|
+
if args.next < 0:
|
|
500
|
+
raise RunError("invalid_next", str(args.next))
|
|
501
|
+
# Dispatch one batch at a time without re-reading the whole remaining plan;
|
|
502
|
+
# counts above still describe everything that is left.
|
|
503
|
+
payload["batches"] = batches[:args.next]
|
|
504
|
+
payload["summary_gaps"] = ["%s-%s" % pair if pair[0] != pair[1] else str(pair[0])
|
|
505
|
+
for pair in compact_ranges(summary_gaps)]
|
|
506
|
+
return payload
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def parse_batch_id(batch_id: str) -> Tuple[str, int, int]:
|
|
510
|
+
match = BATCH_ID_RE.fullmatch(batch_id)
|
|
511
|
+
if not match:
|
|
512
|
+
raise RunError("invalid_batch_id", batch_id)
|
|
513
|
+
start, end = int(match.group(2)), int(match.group(3))
|
|
514
|
+
if start < 1 or end < start:
|
|
515
|
+
raise RunError("invalid_batch_range", batch_id)
|
|
516
|
+
return ("raw-original" if match.group(1) == "RAW" else "existing-results", start, end)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def compact_field(body: str, name: str) -> str:
|
|
520
|
+
match = re.search(r"(?m)^\*\*%s\*\*\s*[::]\s*(\S.*)$" % re.escape(name), body)
|
|
521
|
+
if not match:
|
|
522
|
+
raise RunError("chapter_schema_incomplete", "missing field: %s" % name)
|
|
523
|
+
value = match.group(1).strip()
|
|
524
|
+
if "{" in value or "}" in value:
|
|
525
|
+
raise RunError("template_placeholder", name)
|
|
526
|
+
return value
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def map_enum(value: str, allowed: Sequence[str], aliases: Dict[str, str], default: str = "其他") -> str:
|
|
530
|
+
"""Map one enum value; with several listed values, the first that maps wins."""
|
|
531
|
+
for token in [value.strip()] + re.split(r"[、/,,;;\s]+", value.strip()):
|
|
532
|
+
if token in allowed:
|
|
533
|
+
return token
|
|
534
|
+
if token in aliases:
|
|
535
|
+
return aliases[token]
|
|
536
|
+
for item in allowed:
|
|
537
|
+
if item != "其他" and token.startswith(item):
|
|
538
|
+
return item
|
|
539
|
+
return default
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def parse_plot_points(body: str, chapter: int, minimum: int) -> List[Dict[str, Any]]:
|
|
543
|
+
"""Parse the repeated ``P{n}`` blocks that follow ``**情节点**:``."""
|
|
544
|
+
match = re.search(r"(?m)^\*\*情节点\*\*\s*[::]\s*$", body)
|
|
545
|
+
if not match:
|
|
546
|
+
raise RunError("chapter_schema_incomplete", "chapter %s missing field: 情节点" % chapter)
|
|
547
|
+
points = [] # type: List[Dict[str, Any]]
|
|
548
|
+
for line in body[match.end():].split("\n"):
|
|
549
|
+
stripped = line.strip()
|
|
550
|
+
header = POINT_HEADER_RE.match(stripped)
|
|
551
|
+
if header:
|
|
552
|
+
segments = [item.strip() for item in re.split(r"[||]", header.group(3))]
|
|
553
|
+
# Tolerate the tag written inline at the end of the P line.
|
|
554
|
+
inline = POINT_TAG_RE.match(" | ".join(segments[-2:])) if len(segments) >= 3 else None
|
|
555
|
+
rest = segments[1:-2] if inline else segments[1:]
|
|
556
|
+
if not segments[0].startswith("类型") or not rest or not rest[0]:
|
|
557
|
+
raise RunError("plot_point_invalid", "chapter %s P%s needs 类型 and 白描" % (chapter, header.group(1)))
|
|
558
|
+
points.append({"number": int(header.group(1)), "title": header.group(2).strip(),
|
|
559
|
+
"type": segments[0][2:].lstrip(" ::"), "rest": rest, "quote": [],
|
|
560
|
+
"tag": (inline.group(1), inline.group(2)) if inline else None})
|
|
561
|
+
continue
|
|
562
|
+
if not stripped or stripped == "---":
|
|
563
|
+
continue
|
|
564
|
+
if not points or points[-1]["tag"] is not None:
|
|
565
|
+
raise RunError("plot_point_invalid", "chapter %s: unexpected line %r" % (chapter, stripped[:40]))
|
|
566
|
+
tag = POINT_TAG_RE.match(stripped)
|
|
567
|
+
if tag:
|
|
568
|
+
points[-1]["tag"] = (tag.group(1), tag.group(2))
|
|
569
|
+
else:
|
|
570
|
+
points[-1]["quote"].append(stripped)
|
|
571
|
+
if [point["number"] for point in points] != list(range(1, len(points) + 1)):
|
|
572
|
+
raise RunError("plot_point_invalid", "chapter %s: points must be numbered P1..Pn" % chapter)
|
|
573
|
+
if not minimum <= len(points) <= MAX_PLOT_POINTS:
|
|
574
|
+
raise RunError("plot_point_count", "chapter %s has %s points; expected %s-%s"
|
|
575
|
+
% (chapter, len(points), minimum, MAX_PLOT_POINTS))
|
|
576
|
+
for point in points:
|
|
577
|
+
if point["tag"] is None:
|
|
578
|
+
raise RunError("plot_point_invalid", "chapter %s P%s missing 主题标签/基调 line" % (chapter, point["number"]))
|
|
579
|
+
text = "%s %s %s" % (point["title"], " ".join(point["rest"]), " ".join(point["quote"]))
|
|
580
|
+
if "{" in text or "}" in text:
|
|
581
|
+
raise RunError("template_placeholder", "chapter %s P%s" % (chapter, point["number"]))
|
|
582
|
+
return points
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def parse_model_output(text: str, start: int, end: int, input_kind: str) -> Dict[int, Dict[str, Any]]:
|
|
586
|
+
text = normalized(text)
|
|
587
|
+
if "BATCH_ERROR:" in text:
|
|
588
|
+
raise RunError("extractor_reported_error", "model returned BATCH_ERROR")
|
|
589
|
+
if any(marker in text for marker in (CACHE_START, CACHE_END, MODEL_START, MODEL_END)):
|
|
590
|
+
raise RunError("reserved_marker_in_output", "model output contains a runtime cache marker")
|
|
591
|
+
expected_tokens = [token for chapter in range(start, end + 1) for token in (("START", chapter), ("END", chapter))]
|
|
592
|
+
actual_tokens = [(match.group(1), int(match.group(2))) for match in CHAPTER_TOKEN_RE.finditer(text)]
|
|
593
|
+
if input_kind == "raw-original" and actual_tokens != expected_tokens:
|
|
594
|
+
raise RunError("chapter_marker_mismatch", "expected %s; received %s" % (expected_tokens, actual_tokens))
|
|
595
|
+
if actual_tokens and actual_tokens != expected_tokens:
|
|
596
|
+
raise RunError("chapter_marker_mismatch", "expected %s; received %s" % (expected_tokens, actual_tokens))
|
|
597
|
+
# Projections rebuilt from old results may hold fewer beats than a fresh reading.
|
|
598
|
+
minimum = MIN_PLOT_POINTS if input_kind == "raw-original" else 1
|
|
599
|
+
records = {} # type: Dict[int, Dict[str, Any]]
|
|
600
|
+
for match in CHAPTER_BLOCK_RE.finditer(text):
|
|
601
|
+
chapter = int(match.group(1))
|
|
602
|
+
body = match.group(2).strip()
|
|
603
|
+
if not re.search(r"(?m)^##\s+第%s章(?:\s+.*)?$" % chapter, body):
|
|
604
|
+
raise RunError("chapter_schema_incomplete", "chapter %s heading missing" % chapter)
|
|
605
|
+
fields = {name: compact_field(body, name) for name in COMPACT_FIELDS} # type: Dict[str, Any]
|
|
606
|
+
fields["情节点"] = parse_plot_points(body, chapter, minimum)
|
|
607
|
+
records[chapter] = fields
|
|
608
|
+
if input_kind == "raw-original" and set(records) != set(range(start, end + 1)):
|
|
609
|
+
raise RunError("chapter_block_missing", "%s-%s" % (start, end))
|
|
610
|
+
if records and set(records) != set(range(start, end + 1)):
|
|
611
|
+
raise RunError("chapter_block_missing", "partial reuse projection is not allowed")
|
|
612
|
+
if not records and input_kind == "existing-results":
|
|
613
|
+
marker = re.search(r"<!--\s*REUSED_CHAPTERS:(\d+)-(\d+)\s*-->", text)
|
|
614
|
+
if not marker or (int(marker.group(1)), int(marker.group(2))) != (start, end):
|
|
615
|
+
raise RunError("reused_range_mismatch", "%s-%s" % (start, end))
|
|
616
|
+
if text.count("<!-- BATCH_OBSERVATIONS_START -->") != 1 or text.count("<!-- BATCH_OBSERVATIONS_END -->") != 1:
|
|
617
|
+
raise RunError("batch_marker_mismatch", "one complete cross-chapter observation block is required")
|
|
618
|
+
observation_start = text.index("<!-- BATCH_OBSERVATIONS_START -->")
|
|
619
|
+
observation_end = text.index("<!-- BATCH_OBSERVATIONS_END -->")
|
|
620
|
+
last_source_marker = max((match.end() for match in CHAPTER_TOKEN_RE.finditer(text)), default=0)
|
|
621
|
+
reused_marker = re.search(r"<!--\s*REUSED_CHAPTERS:\d+-\d+\s*-->", text)
|
|
622
|
+
if reused_marker:
|
|
623
|
+
last_source_marker = max(last_source_marker, reused_marker.end())
|
|
624
|
+
if observation_start < last_source_marker or observation_end <= observation_start:
|
|
625
|
+
raise RunError("batch_marker_order", "cross-chapter observations must follow source coverage")
|
|
626
|
+
return records
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def render_plot_point(point: Dict[str, Any]) -> str:
|
|
630
|
+
point_type = map_enum(point["type"], POINT_TYPES, POINT_ALIASES, default="行动")
|
|
631
|
+
theme = map_enum(point["tag"][0], THEMES, THEME_ALIASES)
|
|
632
|
+
tone = map_enum(point["tag"][1], TONES, TONE_ALIASES)
|
|
633
|
+
lines = ["P%s **%s**:%s" % (point["number"], point["title"], " | ".join(["类型" + point_type] + point["rest"]))]
|
|
634
|
+
lines.extend(point["quote"])
|
|
635
|
+
lines.extend(["", "主题标签%s | 基调:%s" % (theme, tone)])
|
|
636
|
+
return "\n".join(lines)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def render_summary(chapter: int, fields: Dict[str, Any], source_kind: str,
|
|
640
|
+
chapter_hash: str, batch_id: str) -> bytes:
|
|
641
|
+
text = (
|
|
642
|
+
"<!-- story-long-analyze:projection runtime=single-state-v1 source=%s chapter_sha256=%s batch=%s -->\n"
|
|
643
|
+
"## 第%s章\n\n**概要**:%s\n\n**关键事件**:\n1. %s\n\n"
|
|
644
|
+
"**因果**:%s\n\n**局面结果**:%s\n\n**涉及**:%s\n\n"
|
|
645
|
+
"**信息变化**:%s\n\n**状态变化**:%s\n\n**三维节奏**:%s\n\n"
|
|
646
|
+
"**章尾钩子**:%s\n\n**证据**:%s\n\n**情节点**:\n\n%s\n"
|
|
647
|
+
) % (
|
|
648
|
+
source_kind, chapter_hash, batch_id, chapter, fields["概要"], fields["关键行动"],
|
|
649
|
+
fields["因果"], fields["局面结果"], fields["涉及人物"], fields["信息变化"],
|
|
650
|
+
fields["状态变化"], fields["三维节奏"], fields["章尾钩子"], fields["证据"],
|
|
651
|
+
"\n\n---\n\n".join(render_plot_point(point) for point in fields["情节点"]),
|
|
652
|
+
)
|
|
653
|
+
return text.encode("utf-8")
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def render_cache(batch_id: str, start: int, end: int, input_kind: str,
|
|
657
|
+
range_hash: str, source_files: Sequence[str], model_output: str,
|
|
658
|
+
projection_schema: str = "compact-v3") -> bytes:
|
|
659
|
+
text = (
|
|
660
|
+
"%s\n# 批次 %s\n- batch_id: %s\n- chapters: %s-%s\n- input_kind: %s\n"
|
|
661
|
+
"- range_sha256: %s\n- projection_schema: %s\n"
|
|
662
|
+
"- source_files: %s\n%s\n%s\n%s\n%s\n"
|
|
663
|
+
) % (CACHE_START, batch_id, batch_id, start, end, input_kind, range_hash, projection_schema,
|
|
664
|
+
json.dumps(list(source_files), ensure_ascii=False), MODEL_START,
|
|
665
|
+
normalized(model_output).strip(), MODEL_END, CACHE_END)
|
|
666
|
+
return text.encode("utf-8")
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def parse_cache(path: Path) -> Dict[str, Any]:
|
|
670
|
+
text = normalized(path.read_text(encoding="utf-8-sig"))
|
|
671
|
+
if not text.rstrip().endswith(CACHE_END):
|
|
672
|
+
raise RunError("cache_incomplete", str(path))
|
|
673
|
+
metadata = {} # type: Dict[str, Any]
|
|
674
|
+
for key in ("batch_id", "chapters", "input_kind", "range_sha256", "projection_schema"):
|
|
675
|
+
match = re.search(r"(?m)^- %s:\s*(.+)$" % key, text)
|
|
676
|
+
if not match:
|
|
677
|
+
raise RunError("cache_invalid", "missing %s" % key)
|
|
678
|
+
metadata[key] = match.group(1).strip()
|
|
679
|
+
try:
|
|
680
|
+
start, end = [int(value) for value in metadata["chapters"].split("-", 1)]
|
|
681
|
+
except ValueError:
|
|
682
|
+
raise RunError("cache_invalid", "chapters: %s" % metadata["chapters"])
|
|
683
|
+
model_match = re.search(re.escape(MODEL_START) + r"\n(.*?)\n" + re.escape(MODEL_END), text, re.DOTALL)
|
|
684
|
+
if not model_match:
|
|
685
|
+
raise RunError("cache_invalid", "model output markers missing")
|
|
686
|
+
metadata.update({"start": start, "end": end, "model_output": model_match.group(1)})
|
|
687
|
+
return metadata
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def source_files_from_args(values: Optional[Sequence[str]]) -> List[str]:
|
|
691
|
+
return list(values or [])
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def commit_from_cache(root: Path, metadata: Dict[str, Any], cache: Path,
|
|
695
|
+
index_rows: Optional[Sequence[Dict[str, Any]]]) -> Dict[str, Any]:
|
|
696
|
+
batch_id = metadata["batch_id"]
|
|
697
|
+
input_kind, id_start, id_end = parse_batch_id(batch_id)
|
|
698
|
+
start, end = metadata["start"], metadata["end"]
|
|
699
|
+
if (start, end, input_kind) != (id_start, id_end, metadata["input_kind"]):
|
|
700
|
+
raise RunError("cache_invalid", "batch metadata mismatch")
|
|
701
|
+
if input_kind == "raw-original":
|
|
702
|
+
if index_rows is None:
|
|
703
|
+
raise RunError("chapter_index_required", batch_id)
|
|
704
|
+
current_hash = range_sha256(index_rows, start, end)
|
|
705
|
+
if metadata["range_sha256"] != current_hash:
|
|
706
|
+
raise RunError("range_hash_mismatch", batch_id)
|
|
707
|
+
else:
|
|
708
|
+
current_hash = metadata["range_sha256"]
|
|
709
|
+
records = parse_model_output(metadata["model_output"], start, end, input_kind)
|
|
710
|
+
hashes = {row["chapter"]: row["chapter_sha256"] for row in index_rows or []}
|
|
711
|
+
created = []
|
|
712
|
+
kept = []
|
|
713
|
+
for chapter, fields in sorted(records.items()):
|
|
714
|
+
path = summary_path(root, chapter)
|
|
715
|
+
if path.exists():
|
|
716
|
+
kept.append(chapter)
|
|
717
|
+
continue
|
|
718
|
+
chapter_hash = hashes.get(chapter, "0" * 64)
|
|
719
|
+
atomic_write(path, render_summary(chapter, fields, input_kind, chapter_hash, batch_id))
|
|
720
|
+
created.append(path.relative_to(root).as_posix())
|
|
721
|
+
failure_after = os.environ.get("STORY_ANALYZE_FAIL_AFTER_SUMMARIES")
|
|
722
|
+
if failure_after and len(created) >= int(failure_after):
|
|
723
|
+
raise OSError("injected_failure_after_%s_summaries" % failure_after)
|
|
724
|
+
missing = [chapter for chapter in range(start, end + 1) if not summary_path(root, chapter).is_file()]
|
|
725
|
+
if missing:
|
|
726
|
+
raise RunError("summary_projection_missing", ",".join(map(str, missing)))
|
|
727
|
+
state, _ = load_state(root / "_progress.md")
|
|
728
|
+
state["batches"][batch_id] = {
|
|
729
|
+
"batch_id": batch_id, "start": start, "end": end, "input_kind": input_kind,
|
|
730
|
+
"range_sha256": current_hash, "status": "completed", "parent": "",
|
|
731
|
+
"cache": cache.relative_to(root).as_posix(),
|
|
732
|
+
}
|
|
733
|
+
changed = write_state(root / "_progress.md", state)
|
|
734
|
+
return {"batch_id": batch_id, "created_summaries": created,
|
|
735
|
+
"kept_existing_summary_chapters": kept, "progress_updated": changed}
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def commit_command(args: argparse.Namespace) -> Dict[str, Any]:
|
|
739
|
+
root = require_root(args.root)
|
|
740
|
+
input_kind, start, end = parse_batch_id(args.batch_id)
|
|
741
|
+
if end - start + 1 > MAX_CHAPTERS:
|
|
742
|
+
raise RunError("batch_too_large", "%s exceeds %s chapters" % (args.batch_id, MAX_CHAPTERS))
|
|
743
|
+
text = args.input.read_text(encoding="utf-8-sig")
|
|
744
|
+
records = parse_model_output(text, start, end, input_kind)
|
|
745
|
+
index_rows = read_index(root, args.index) if input_kind == "raw-original" or (args.index or root / "chapter_index.csv").is_file() else None
|
|
746
|
+
if input_kind == "raw-original" and end > start:
|
|
747
|
+
total_chars = sum(row["char_count"] for row in index_rows if start <= row["chapter"] <= end)
|
|
748
|
+
if total_chars > MAX_CHARS:
|
|
749
|
+
raise RunError("batch_too_large", "%s exceeds %s characters" % (args.batch_id, MAX_CHARS))
|
|
750
|
+
current_hash = range_sha256(index_rows, start, end) if input_kind == "raw-original" else "existing-results"
|
|
751
|
+
if input_kind == "raw-original" and not args.range_sha256:
|
|
752
|
+
raise RunError("range_hash_required", "pass the value printed by plan")
|
|
753
|
+
if args.range_sha256 and args.range_sha256 != current_hash:
|
|
754
|
+
raise RunError("range_hash_mismatch", args.batch_id)
|
|
755
|
+
# Parsing above validates the whole result before the first write.
|
|
756
|
+
path = cache_path(root, args.batch_id)
|
|
757
|
+
data = render_cache(args.batch_id, start, end, input_kind, current_hash,
|
|
758
|
+
source_files_from_args(args.source_file), text)
|
|
759
|
+
if not path.is_file() or path.read_bytes() != data:
|
|
760
|
+
if path.is_file():
|
|
761
|
+
old_data = path.read_bytes()
|
|
762
|
+
history = root / "_analysis_cache" / "legacy" / (
|
|
763
|
+
"%s.%s.md" % (path.stem, sha256(old_data)[:12])
|
|
764
|
+
)
|
|
765
|
+
if not history.exists():
|
|
766
|
+
atomic_write(history, old_data)
|
|
767
|
+
atomic_write(path, data)
|
|
768
|
+
if os.environ.get("STORY_ANALYZE_FAIL_AFTER_CACHE") == "1":
|
|
769
|
+
raise OSError("injected_failure_after_cache")
|
|
770
|
+
metadata = parse_cache(path)
|
|
771
|
+
result = commit_from_cache(root, metadata, path, index_rows)
|
|
772
|
+
result.update({"ok": True, "cache": path.relative_to(root).as_posix(), "validated_chapters": sorted(records)})
|
|
773
|
+
return result
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def repair_command(args: argparse.Namespace) -> Dict[str, Any]:
|
|
777
|
+
root = require_root(args.root)
|
|
778
|
+
index_rows = read_index(root, args.index) if (args.index or root / "chapter_index.csv").is_file() else None
|
|
779
|
+
paths = [cache_path(root, args.batch_id)] if args.batch_id else sorted((root / "_analysis_cache").glob("批次-*.md"))
|
|
780
|
+
state, _ = load_state(root / "_progress.md")
|
|
781
|
+
repaired = []
|
|
782
|
+
skipped = []
|
|
783
|
+
errors = []
|
|
784
|
+
for path in paths:
|
|
785
|
+
# A cache replaced by a split or by other committed batches is history, not a repair target.
|
|
786
|
+
batch_id = path.stem[len("批次-"):]
|
|
787
|
+
if BATCH_ID_RE.fullmatch(batch_id):
|
|
788
|
+
_, start, end = parse_batch_id(batch_id)
|
|
789
|
+
covered = set()
|
|
790
|
+
for other in state["batches"].values():
|
|
791
|
+
if other["batch_id"] != batch_id and completed_batch(root, other, index_rows):
|
|
792
|
+
covered.update(range(other["start"], other["end"] + 1))
|
|
793
|
+
if state["batches"].get(batch_id, {}).get("status") == "superseded" or set(range(start, end + 1)) <= covered:
|
|
794
|
+
skipped.append({"cache": path.relative_to(root).as_posix(), "reason": "superseded"})
|
|
795
|
+
continue
|
|
796
|
+
try:
|
|
797
|
+
metadata = parse_cache(path)
|
|
798
|
+
repaired.append(commit_from_cache(root, metadata, path, index_rows))
|
|
799
|
+
except (OSError, UnicodeError, RunError) as exc:
|
|
800
|
+
errors.append({"cache": path.as_posix(), "error": getattr(exc, "code", str(exc)), "detail": str(exc)})
|
|
801
|
+
return {"ok": not errors, "repaired": repaired, "skipped": skipped, "errors": errors}
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def split_command(args: argparse.Namespace) -> Dict[str, Any]:
|
|
805
|
+
root = require_root(args.root)
|
|
806
|
+
input_kind, start, end = parse_batch_id(args.batch_id)
|
|
807
|
+
if start >= end:
|
|
808
|
+
raise RunError("batch_not_splittable", args.batch_id)
|
|
809
|
+
index_rows = read_index(root, args.index) if input_kind == "raw-original" else None
|
|
810
|
+
split_at = args.at
|
|
811
|
+
if split_at is None:
|
|
812
|
+
if index_rows:
|
|
813
|
+
counts = {row["chapter"]: row["char_count"] for row in index_rows}
|
|
814
|
+
total = sum(counts[chapter] for chapter in range(start, end + 1))
|
|
815
|
+
running = 0
|
|
816
|
+
split_at = start
|
|
817
|
+
for chapter in range(start, end):
|
|
818
|
+
running += counts[chapter]
|
|
819
|
+
split_at = chapter
|
|
820
|
+
if running >= total / 2:
|
|
821
|
+
break
|
|
822
|
+
else:
|
|
823
|
+
split_at = (start + end) // 2
|
|
824
|
+
if split_at < start or split_at >= end:
|
|
825
|
+
raise RunError("invalid_split_point", str(split_at))
|
|
826
|
+
state, _ = load_state(root / "_progress.md")
|
|
827
|
+
parent_hash = range_sha256(index_rows, start, end) if index_rows else "existing-results"
|
|
828
|
+
state["batches"][args.batch_id] = {
|
|
829
|
+
"batch_id": args.batch_id, "start": start, "end": end, "input_kind": input_kind,
|
|
830
|
+
"range_sha256": parent_hash, "status": "superseded", "parent": "", "cache": "",
|
|
831
|
+
}
|
|
832
|
+
prefix = "RAW" if input_kind == "raw-original" else "REUSE"
|
|
833
|
+
children = []
|
|
834
|
+
for child_start, child_end in ((start, split_at), (split_at + 1, end)):
|
|
835
|
+
child_id = "%s-%s-%s" % (prefix, child_start, child_end)
|
|
836
|
+
child_hash = range_sha256(index_rows, child_start, child_end) if index_rows else "existing-results"
|
|
837
|
+
existing = state["batches"].get(child_id)
|
|
838
|
+
if not existing or existing.get("status") not in {"completed", "success"}:
|
|
839
|
+
state["batches"][child_id] = {
|
|
840
|
+
"batch_id": child_id, "start": child_start, "end": child_end,
|
|
841
|
+
"input_kind": input_kind, "range_sha256": child_hash, "status": "planned",
|
|
842
|
+
"parent": args.batch_id, "cache": "",
|
|
843
|
+
}
|
|
844
|
+
children.append(child_id)
|
|
845
|
+
write_state(root / "_progress.md", state)
|
|
846
|
+
return {"ok": True, "parent": args.batch_id, "status": "superseded", "children": children}
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def stage_required_outputs(root: Path, stage: str) -> List[str]:
|
|
850
|
+
if stage == "stage1":
|
|
851
|
+
expected = inspect(root, None).get("expected_chapters") or 3
|
|
852
|
+
required = ["章节/第%s章_深度拆解.md" % chapter for chapter in range(1, min(3, int(expected)) + 1)]
|
|
853
|
+
required.append("快速预览.md")
|
|
854
|
+
return required
|
|
855
|
+
if stage == "stage2":
|
|
856
|
+
expected = inspect(root, None).get("expected_chapters")
|
|
857
|
+
if not expected:
|
|
858
|
+
raise RunError("expected_chapters_unknown", "Stage 2 completion requires a known chapter count")
|
|
859
|
+
return ["章节/第%s章_摘要.md" % chapter for chapter in range(1, int(expected) + 1)]
|
|
860
|
+
if stage == "stage3":
|
|
861
|
+
return ["剧情/情绪模块.md", "剧情/节奏.md"]
|
|
862
|
+
if stage == "stage4":
|
|
863
|
+
character_files = sorted(path for path in (root / "角色").glob("*.md") if path.is_file() and path.stat().st_size)
|
|
864
|
+
setting_files = sorted(path for path in (root / "设定").rglob("*.md") if path.is_file() and path.stat().st_size)
|
|
865
|
+
missing = []
|
|
866
|
+
if not character_files:
|
|
867
|
+
missing.append("角色/*.md")
|
|
868
|
+
if not setting_files:
|
|
869
|
+
missing.append("设定/**/*.md")
|
|
870
|
+
if missing:
|
|
871
|
+
raise RunError("stage_output_missing", ",".join(missing))
|
|
872
|
+
return [character_files[0].relative_to(root).as_posix(), setting_files[0].relative_to(root).as_posix()]
|
|
873
|
+
if stage == "stage5":
|
|
874
|
+
return ["拆文报告.md"]
|
|
875
|
+
if stage == "stage6":
|
|
876
|
+
return ["文风.md"]
|
|
877
|
+
raise RunError("stage_unknown", stage)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def mark_stage_command(args: argparse.Namespace) -> Dict[str, Any]:
|
|
881
|
+
root = require_root(args.root)
|
|
882
|
+
if args.prepare:
|
|
883
|
+
if args.stage != "stage5":
|
|
884
|
+
raise RunError("prepare_stage_unsupported", args.stage)
|
|
885
|
+
report = root / "拆文报告.md"
|
|
886
|
+
backup = root / "_analysis_cache" / "legacy" / "拆文报告.md"
|
|
887
|
+
created = False
|
|
888
|
+
selected_backup = backup
|
|
889
|
+
if report.is_file():
|
|
890
|
+
report_data = report.read_bytes()
|
|
891
|
+
if backup.exists() and backup.read_bytes() != report_data:
|
|
892
|
+
selected_backup = backup.with_name("拆文报告.%s.md" % sha256(report_data)[:12])
|
|
893
|
+
if not selected_backup.exists():
|
|
894
|
+
atomic_write(selected_backup, report_data)
|
|
895
|
+
created = True
|
|
896
|
+
return {"ok": True, "stage": args.stage, "prepared": True,
|
|
897
|
+
"legacy_report_backup": selected_backup.relative_to(root).as_posix() if selected_backup.is_file() else None,
|
|
898
|
+
"backup_created": created, "progress_updated": False}
|
|
899
|
+
required_outputs = stage_required_outputs(root, args.stage) if args.status == "completed" else []
|
|
900
|
+
missing_required = [
|
|
901
|
+
name for name in required_outputs
|
|
902
|
+
if not (root / name).is_file() or (root / name).stat().st_size == 0
|
|
903
|
+
]
|
|
904
|
+
if missing_required:
|
|
905
|
+
raise RunError("stage_output_missing", ",".join(missing_required))
|
|
906
|
+
output = args.output
|
|
907
|
+
relative_output = ""
|
|
908
|
+
if output:
|
|
909
|
+
path = output if output.is_absolute() else root / output
|
|
910
|
+
if args.status == "completed" and (not path.is_file() or path.stat().st_size == 0):
|
|
911
|
+
raise RunError("stage_output_missing", str(path))
|
|
912
|
+
try:
|
|
913
|
+
relative_output = path.resolve().relative_to(root).as_posix()
|
|
914
|
+
except ValueError:
|
|
915
|
+
raise RunError("stage_output_outside_root", str(path))
|
|
916
|
+
elif required_outputs:
|
|
917
|
+
relative_output = ";".join(required_outputs)
|
|
918
|
+
state, _ = load_state(root / "_progress.md")
|
|
919
|
+
state["stages"][args.stage] = {"status": args.status, "output": relative_output}
|
|
920
|
+
changed = write_state(root / "_progress.md", state)
|
|
921
|
+
return {"ok": True, "stage": args.stage, "status": args.status, "progress_updated": changed}
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
OBS_START = "<!-- BATCH_OBSERVATIONS_START -->"
|
|
925
|
+
OBS_END = "<!-- BATCH_OBSERVATIONS_END -->"
|
|
926
|
+
# Labels as projected by render_summary (legacy summaries use the same labels).
|
|
927
|
+
SUMMARY_FIELDS = ("概要", "关键事件", "因果", "局面结果", "涉及", "信息变化", "状态变化",
|
|
928
|
+
"三维节奏", "章尾钩子", "证据")
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def parse_chapter_window(value: Optional[str]) -> Optional[Tuple[int, int]]:
|
|
932
|
+
if not value:
|
|
933
|
+
return None
|
|
934
|
+
match = re.fullmatch(r"(\d+)(?:-(\d+))?", value.strip())
|
|
935
|
+
if not match:
|
|
936
|
+
raise RunError("invalid_chapter_window", value)
|
|
937
|
+
start = int(match.group(1))
|
|
938
|
+
end = int(match.group(2) or start)
|
|
939
|
+
if start < 1 or end < start:
|
|
940
|
+
raise RunError("invalid_chapter_window", value)
|
|
941
|
+
return start, end
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def digest_caches(root: Path) -> List[Tuple[int, int, str, Path]]:
|
|
945
|
+
"""Complete caches of committed batches, falling back to every complete cache on disk."""
|
|
946
|
+
state, _ = load_state(root / "_progress.md")
|
|
947
|
+
chosen = {} # type: Dict[str, Tuple[int, int, str, Path]]
|
|
948
|
+
for row in state["batches"].values():
|
|
949
|
+
if row.get("status") not in {"completed", "success"}:
|
|
950
|
+
continue
|
|
951
|
+
path = root / row["cache"] if row.get("cache") else cache_path(root, row["batch_id"])
|
|
952
|
+
if cache_complete(path):
|
|
953
|
+
chosen[row["batch_id"]] = (row["start"], row["end"], row["batch_id"], path)
|
|
954
|
+
if not chosen:
|
|
955
|
+
for path in sorted((root / "_analysis_cache").glob("批次-*.md")):
|
|
956
|
+
try:
|
|
957
|
+
_, start, end = parse_batch_id(path.stem[len("批次-"):])
|
|
958
|
+
except RunError:
|
|
959
|
+
continue
|
|
960
|
+
if cache_complete(path):
|
|
961
|
+
chosen[path.stem] = (start, end, path.stem[len("批次-"):], path)
|
|
962
|
+
return sorted(chosen.values())
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def summary_field(text: str, name: str) -> str:
|
|
966
|
+
if name == "关键事件":
|
|
967
|
+
match = re.search(r"(?ms)^\*\*关键事件\*\*\s*[::]\s*\n(.*?)(?=^\*\*|\Z)", text)
|
|
968
|
+
return " ".join(line.strip() for line in match.group(1).splitlines() if line.strip()) if match else ""
|
|
969
|
+
match = re.search(r"(?m)^\*\*%s\*\*\s*[::]\s*(\S.*)$" % re.escape(name), text)
|
|
970
|
+
return match.group(1).strip() if match else ""
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def summary_points(text: str, mode: str) -> List[str]:
|
|
974
|
+
lines = text.split("\n")
|
|
975
|
+
points = [] # type: List[str]
|
|
976
|
+
for index, line in enumerate(lines):
|
|
977
|
+
header = POINT_HEADER_RE.match(line.strip())
|
|
978
|
+
if not header:
|
|
979
|
+
continue
|
|
980
|
+
segments = [item.strip() for item in re.split(r"[||]", header.group(3))]
|
|
981
|
+
tone = ""
|
|
982
|
+
for follow in lines[index + 1:index + 8]:
|
|
983
|
+
tag = POINT_TAG_RE.match(follow.strip())
|
|
984
|
+
if tag:
|
|
985
|
+
tone = tag.group(2)
|
|
986
|
+
break
|
|
987
|
+
if POINT_HEADER_RE.match(follow.strip()):
|
|
988
|
+
break
|
|
989
|
+
point_type = segments[0][2:].lstrip(" ::") if segments[0].startswith("类型") else segments[0]
|
|
990
|
+
if mode == "brief":
|
|
991
|
+
points.append("P%s %s|%s|%s" % (header.group(1), header.group(2).strip(), point_type, tone or "—"))
|
|
992
|
+
else:
|
|
993
|
+
points.append("%s |基调:%s" % (line.strip(), tone or "—"))
|
|
994
|
+
return points
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def digest_command(args: argparse.Namespace) -> Dict[str, Any]:
|
|
998
|
+
root = require_root(args.root)
|
|
999
|
+
window = parse_chapter_window(args.chapters)
|
|
1000
|
+
parts = [] # type: List[str]
|
|
1001
|
+
if args.part == "observations":
|
|
1002
|
+
for start, end, batch_id, path in digest_caches(root):
|
|
1003
|
+
if window and (end < window[0] or start > window[1]):
|
|
1004
|
+
continue
|
|
1005
|
+
model = parse_cache(path)["model_output"]
|
|
1006
|
+
if OBS_START not in model or OBS_END not in model:
|
|
1007
|
+
continue
|
|
1008
|
+
body = model.split(OBS_START, 1)[1].split(OBS_END, 1)[0].strip()
|
|
1009
|
+
body = re.sub(r"(?m)^##\s*跨章观察\s*\n", "", body).strip()
|
|
1010
|
+
parts.append("## %s(第%s-%s章)\n\n%s" % (batch_id, start, end, body))
|
|
1011
|
+
else:
|
|
1012
|
+
fields = [item.strip() for item in (args.fields or "").split(",") if item.strip()]
|
|
1013
|
+
unknown = [item for item in fields if item not in SUMMARY_FIELDS]
|
|
1014
|
+
if unknown:
|
|
1015
|
+
raise RunError("unknown_summary_field", ",".join(unknown))
|
|
1016
|
+
if not fields and args.points == "none":
|
|
1017
|
+
raise RunError("digest_empty_request", "pass --fields and/or --points")
|
|
1018
|
+
chapters = sorted(
|
|
1019
|
+
int(match.group(1)) for match in
|
|
1020
|
+
(re.fullmatch(r"第(\d+)章_摘要", path.stem) for path in (root / "章节").glob("第*章_摘要.md"))
|
|
1021
|
+
if match
|
|
1022
|
+
)
|
|
1023
|
+
for chapter in chapters:
|
|
1024
|
+
if window and not window[0] <= chapter <= window[1]:
|
|
1025
|
+
continue
|
|
1026
|
+
text = normalized(summary_path(root, chapter).read_text(encoding="utf-8-sig"))
|
|
1027
|
+
lines = ["### 第%s章" % chapter]
|
|
1028
|
+
lines.extend("- %s:%s" % (name, summary_field(text, name) or "未提供") for name in fields)
|
|
1029
|
+
if args.points != "none":
|
|
1030
|
+
lines.extend(summary_points(text, args.points))
|
|
1031
|
+
parts.append("\n".join(lines))
|
|
1032
|
+
if not parts:
|
|
1033
|
+
raise RunError("digest_nothing_found", "no committed batch caches or summaries in range")
|
|
1034
|
+
return {"ok": True, "text": "\n\n".join(parts) + "\n"}
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1038
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
1039
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1040
|
+
plan = sub.add_parser("plan", help="print a read-only in-memory plan")
|
|
1041
|
+
plan.add_argument("--root", required=True, type=Path)
|
|
1042
|
+
plan.add_argument("--index", type=Path)
|
|
1043
|
+
plan.add_argument("--expected-chapters", type=int)
|
|
1044
|
+
plan.add_argument("--intent", choices=("continue", "enhance"), default="continue")
|
|
1045
|
+
plan.add_argument("--next", type=int, metavar="N",
|
|
1046
|
+
help="only print the first N batches (0 = counts only); remaining_batches stays total")
|
|
1047
|
+
plan.set_defaults(handler=plan_command)
|
|
1048
|
+
commit = sub.add_parser("commit", help="validate and atomically commit one batch")
|
|
1049
|
+
commit.add_argument("--root", required=True, type=Path)
|
|
1050
|
+
commit.add_argument("--input", required=True, type=Path)
|
|
1051
|
+
commit.add_argument("--batch-id", required=True)
|
|
1052
|
+
commit.add_argument("--range-sha256")
|
|
1053
|
+
commit.add_argument("--index", type=Path)
|
|
1054
|
+
commit.add_argument("--source-file", action="append")
|
|
1055
|
+
commit.set_defaults(handler=commit_command)
|
|
1056
|
+
split = sub.add_parser("split", help="persist a failed batch split")
|
|
1057
|
+
split.add_argument("--root", required=True, type=Path)
|
|
1058
|
+
split.add_argument("--batch-id", required=True)
|
|
1059
|
+
split.add_argument("--at", type=int)
|
|
1060
|
+
split.add_argument("--index", type=Path)
|
|
1061
|
+
split.set_defaults(handler=split_command)
|
|
1062
|
+
repair = sub.add_parser("repair-progress", help="recover missing projections/state from complete caches")
|
|
1063
|
+
repair.add_argument("--root", required=True, type=Path)
|
|
1064
|
+
repair.add_argument("--batch-id")
|
|
1065
|
+
repair.add_argument("--index", type=Path)
|
|
1066
|
+
repair.set_defaults(handler=repair_command)
|
|
1067
|
+
stage = sub.add_parser("mark-stage", help="mark a stage after its output is present")
|
|
1068
|
+
stage.add_argument("--root", required=True, type=Path)
|
|
1069
|
+
stage.add_argument("--stage", required=True, choices=("stage1", "stage2", "stage3", "stage4", "stage5", "stage6"))
|
|
1070
|
+
stage.add_argument("--status", choices=("completed", "completed_with_errors"), default="completed")
|
|
1071
|
+
stage.add_argument("--output", type=Path)
|
|
1072
|
+
stage.add_argument("--prepare", action="store_true", help="before Stage 5, preserve the existing report")
|
|
1073
|
+
stage.set_defaults(handler=mark_stage_command)
|
|
1074
|
+
digest = sub.add_parser("digest", help="print Stage 3-5 reading material without whole-cache reads")
|
|
1075
|
+
digest.add_argument("--root", required=True, type=Path)
|
|
1076
|
+
digest.add_argument("--part", required=True, choices=("observations", "chapters"))
|
|
1077
|
+
digest.add_argument("--chapters", help="chapter window such as 1-40")
|
|
1078
|
+
digest.add_argument("--fields", help="comma-separated summary fields, e.g. 三维节奏,涉及,状态变化")
|
|
1079
|
+
digest.add_argument("--points", choices=("none", "brief", "full"), default="none")
|
|
1080
|
+
digest.set_defaults(handler=digest_command)
|
|
1081
|
+
return parser
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def main() -> int:
|
|
1085
|
+
for stream in (sys.stdout, sys.stderr):
|
|
1086
|
+
if hasattr(stream, "reconfigure"):
|
|
1087
|
+
stream.reconfigure(encoding="utf-8")
|
|
1088
|
+
parser = build_parser()
|
|
1089
|
+
args = parser.parse_args()
|
|
1090
|
+
try:
|
|
1091
|
+
payload = args.handler(args)
|
|
1092
|
+
if args.command == "digest":
|
|
1093
|
+
sys.stdout.write(payload["text"])
|
|
1094
|
+
return 0
|
|
1095
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
|
|
1096
|
+
return 0 if payload.get("ok", True) else 2
|
|
1097
|
+
except (OSError, UnicodeError, RunError) as exc:
|
|
1098
|
+
payload = {"ok": False, "error": getattr(exc, "code", "io_error"), "detail": str(exc)}
|
|
1099
|
+
if getattr(exc, "author_message", None):
|
|
1100
|
+
payload["author_message"] = exc.author_message
|
|
1101
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
1102
|
+
return 2
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
if __name__ == "__main__":
|
|
1106
|
+
raise SystemExit(main())
|