mirror of
https://github.com/khodges42/nightShift.git
synced 2026-06-14 10:08:37 +00:00
Refactor of writing tool
This commit is contained in:
parent
e1e6803eb1
commit
78dcf911d6
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -29,6 +29,9 @@ MANIFEST
|
|||
# Codex working notes and generated analysis docs
|
||||
docs/codex/
|
||||
|
||||
# Locally preserved pre-pivot writing template
|
||||
tutorial-writing-complex/
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ from .runlog import RunLogger
|
|||
from .stages import StageResult
|
||||
from .tasks import Task, mark_task_completed
|
||||
from .telemetry import TelemetryEntry, format_telemetry_summary, telemetry_from_stage_output
|
||||
from .writing_validators import validate_writing_file_updates
|
||||
from .writing_validators import collect_writing_warnings, validate_writing_file_updates
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -767,6 +767,7 @@ class PipelineRunner:
|
|||
stdout = self._read_agent_stdout(result.output_path)
|
||||
invalid_rerun_done = False
|
||||
candidate_index_path: Path | None = None
|
||||
warning_path: Path | None = None
|
||||
while True:
|
||||
updates: tuple[FileUpdate, ...] = ()
|
||||
try:
|
||||
|
|
@ -779,6 +780,7 @@ class PipelineRunner:
|
|||
)
|
||||
if _is_writing_file_writer_stage(stage):
|
||||
validate_writing_file_updates(updates, self.config.project.root)
|
||||
warning_path = self._write_file_writer_warnings(task.id, stage, updates, retry_count)
|
||||
patch = generate_patch_from_file_updates(
|
||||
updates,
|
||||
self.config.project.root,
|
||||
|
|
@ -799,6 +801,12 @@ class PipelineRunner:
|
|||
):
|
||||
if _is_writing_file_writer_stage(stage):
|
||||
validate_writing_file_updates(allowed_updates, self.config.project.root)
|
||||
warning_path = self._write_file_writer_warnings(
|
||||
task.id,
|
||||
stage,
|
||||
allowed_updates,
|
||||
retry_count,
|
||||
)
|
||||
patch = generate_patch_from_file_updates(
|
||||
allowed_updates,
|
||||
self.config.project.root,
|
||||
|
|
@ -910,7 +918,11 @@ class PipelineRunner:
|
|||
"pass",
|
||||
patch_reason,
|
||||
output_path=str(proposed_path.relative_to(self.config.project.root)),
|
||||
context_update=f"Implementation summary: {summary_path.relative_to(self.config.project.root).as_posix()}",
|
||||
context_update=_format_writer_context_update(
|
||||
self.config.project.root,
|
||||
summary_path,
|
||||
warning_path,
|
||||
),
|
||||
)
|
||||
|
||||
def _write_file_writer_candidates(
|
||||
|
|
@ -954,6 +966,31 @@ class PipelineRunner:
|
|||
lines.append("")
|
||||
return self.artifacts.write_stage_output(task_id, f"{base}/index.md", "\n".join(lines))
|
||||
|
||||
def _write_file_writer_warnings(
|
||||
self,
|
||||
task_id: str,
|
||||
stage: StageConfig,
|
||||
updates: tuple[FileUpdate, ...],
|
||||
retry_count: int,
|
||||
) -> Path | None:
|
||||
warnings = collect_writing_warnings(updates, self.config.project.root)
|
||||
if not warnings:
|
||||
return None
|
||||
filename = _attempt_filename(f"{stage.id}-warnings.md", retry_count)
|
||||
lines = [
|
||||
"# Writing Warnings",
|
||||
"",
|
||||
f"Stage: `{stage.id}`",
|
||||
"",
|
||||
"These are soft writing concerns. They do not block artifact creation.",
|
||||
"",
|
||||
"## Warnings",
|
||||
"",
|
||||
*[f"- {warning}" for warning in warnings],
|
||||
"",
|
||||
]
|
||||
return self.artifacts.write_stage_output(task_id, filename, "\n".join(lines))
|
||||
|
||||
def _allowed_file_contents(self, stage: StageConfig, max_chars: int = 2400) -> str:
|
||||
sections: list[str] = []
|
||||
for path_text in stage.allowed_paths:
|
||||
|
|
@ -1677,6 +1714,18 @@ def format_implementation_summary(
|
|||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_writer_context_update(
|
||||
project_root: Path,
|
||||
summary_path: Path,
|
||||
warning_path: Path | None,
|
||||
) -> str:
|
||||
summary = summary_path.relative_to(project_root).as_posix()
|
||||
if warning_path is None:
|
||||
return f"Implementation summary: {summary}"
|
||||
warnings = warning_path.relative_to(project_root).as_posix()
|
||||
return f"Implementation summary: {summary}; writing warnings: {warnings}"
|
||||
|
||||
|
||||
def _latest_patch_like_output(previous_outputs: dict[str, str]) -> str:
|
||||
for name in ("normalized.patch", "applied.patch", "proposed.patch", "patch_input"):
|
||||
if name in previous_outputs and previous_outputs[name].strip():
|
||||
|
|
|
|||
|
|
@ -16,3 +16,7 @@ story/chapters/*.md
|
|||
.nightshift/project-context.md
|
||||
.nightshift/project-context-chart.md
|
||||
.nightshift/nightshift.log
|
||||
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
You are the continuity reviewer for a NightShift novel-writing workflow.
|
||||
|
||||
Review the drafted scene against:
|
||||
- the current task
|
||||
- `story/worldbuilding.md`
|
||||
- `story/characters.md`
|
||||
- `story/plot-state.md`
|
||||
- `story/timeline.md`
|
||||
- `story/unresolved-threads.md`
|
||||
- `story/continuity-rules.md`
|
||||
- prior scene context provided in artifacts
|
||||
|
||||
Check for:
|
||||
- contradictions
|
||||
- wrong character knowledge
|
||||
- wrong character pronouns or narrative reference, using `Pronouns / Reference` in `story/characters.md` as hard canon
|
||||
- impossible locations or timing
|
||||
- accidental resolution of future threads
|
||||
- missing required beats from the task
|
||||
- invented lore that should have been added deliberately
|
||||
|
||||
Do not fail the scene because durable state files are not updated yet. State files are updated by a later `update_state` stage after review. If the task lists `Updates:`, treat those as future state-update requirements and mention them only as `context_update` guidance.
|
||||
|
||||
Wrong pronouns are a continuity failure. If a drafted scene uses non-canonical pronouns for a named character, return `status: fail` and explain which character drifted. Do not pass the scene with only `context_update` guidance.
|
||||
|
||||
Pronoun canon quick reference:
|
||||
- Proxy: she/her
|
||||
- BLOODMONEY: narrative default they/them; he/him allowed only when dialogue or close character voice has a specific reason
|
||||
- Cricket: she/her
|
||||
- Saint: he/him
|
||||
- Miette: she/her
|
||||
|
||||
If retry notes, previous reviewer output, or generated scene text conflict with `story/characters.md`, obey `story/characters.md`. Do not infer pronouns from a previous failure note. Before failing a pronoun issue, verify the character's `Pronouns / Reference` section.
|
||||
|
||||
Output exactly:
|
||||
|
||||
status: pass | fail | retry | escalate
|
||||
reason: <short explanation>
|
||||
next_stage: <optional stage id>
|
||||
context_update: <compact useful note>
|
||||
|
||||
When `status: pass`, leave `next_stage` blank. Use `retry` when the scene can be repaired by drafting again. For retryable scene issues, leave `next_stage` blank; NightShift will route back to the configured drafting stage.
|
||||
|
|
@ -1,25 +1,22 @@
|
|||
You are the drafting agent for a NightShift novel-writing workflow.
|
||||
You are the scene writer for a NightShift fiction workflow.
|
||||
|
||||
Draft only the current scene or section requested by the task.
|
||||
Write the scene requested by the current task.
|
||||
|
||||
Rules:
|
||||
- Write prose only under `story/chapters/`.
|
||||
- Do not edit `story/worldbuilding.md`, `story/characters.md`, `story/style-guide.md`, `story/plot-state.md`, `story/timeline.md`, `story/unresolved-threads.md`, `story/continuity-rules.md`, or `story/outline.md`.
|
||||
- Use `story/style-guide.md` for POV, tense, tone, and prose rules.
|
||||
- Use `story/plot-state.md` and `story/timeline.md` as current state.
|
||||
- Use the `Pronouns / Reference` sections in `story/characters.md` as hard canon.
|
||||
- Do not infer, vary, or "smooth out" character pronouns. Use canonical narrative reference exactly.
|
||||
- Use `story/characters.md`, especially `Pronouns / Reference`, as canon.
|
||||
- Use `story/plot-state.md`, `story/timeline.md`, and `story/unresolved-threads.md` as current state.
|
||||
- Keep the scene bounded to the task acceptance criteria.
|
||||
- Do not resolve future plot threads unless the task explicitly asks for that.
|
||||
- Do not update state files, character files, worldbuilding, outline, continuity rules, or style guide.
|
||||
- Do not include author notes, TODOs, bracket placeholders, or analysis in the scene file.
|
||||
|
||||
Output only one complete file block using this delimiter format:
|
||||
Output only one complete file block:
|
||||
|
||||
FILE: <the exact story/chapters path listed under Writes in the current task>
|
||||
---CONTENT---
|
||||
<complete scene prose>
|
||||
---END---
|
||||
|
||||
Do not use markdown code fences for scene prose output.
|
||||
Do not output a plan, notes, analysis, or any text outside the delimiter block.
|
||||
Do not use markdown code fences. Do not output any text outside the file block.
|
||||
|
||||
If the task does not specify a scene path, choose the next obvious path under `story/chapters/` and keep it stable.
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
You are the scene editor for a NightShift novel-writing workflow.
|
||||
|
||||
Edit an already drafted scene after a continuity or style review failure.
|
||||
|
||||
Rules:
|
||||
- Preserve the existing scene's structure, voice, events, pacing, and best lines.
|
||||
- Make the smallest changes needed to satisfy the review failure and task acceptance criteria.
|
||||
- Do not restart, summarize, replace the scene premise, or change scene direction.
|
||||
- Use `story/style-guide.md` for POV, tense, tone, and prose rules.
|
||||
- Use `story/characters.md`, especially `Pronouns / Reference`, as hard canon.
|
||||
- Wrong pronouns are mandatory fixes.
|
||||
- If retry notes or reviewer feedback conflict with `story/characters.md`, obey `story/characters.md`.
|
||||
- Never change correct canonical pronouns because a review note claims a different canon.
|
||||
- Do not edit state files, worldbuilding, outline, continuity rules, or style guide.
|
||||
- Do not resolve future plot threads unless the task explicitly asks for that.
|
||||
- Do not include author notes, TODOs, bracket placeholders, or analysis in the scene file.
|
||||
|
||||
Use the `current_scene_file` context as the source text to edit.
|
||||
Use the retry notes and latest review output to identify the required repair.
|
||||
|
||||
Output only one complete file block using this delimiter format:
|
||||
FILE: <the exact story/chapters path listed under Writes in the current task>
|
||||
---CONTENT---
|
||||
<complete edited scene prose>
|
||||
---END---
|
||||
|
||||
Do not use markdown code fences for scene prose output.
|
||||
Do not output a plan, notes, analysis, or any text outside the delimiter block.
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
You are the planning agent for a NightShift novel-writing workflow.
|
||||
|
||||
Create a concise scene plan for the current task.
|
||||
|
||||
Use the story files as source of truth:
|
||||
- `story/worldbuilding.md`
|
||||
- `story/characters.md`
|
||||
- `story/style-guide.md`
|
||||
- `story/plot-state.md`
|
||||
- `story/timeline.md`
|
||||
- `story/unresolved-threads.md`
|
||||
- `story/continuity-rules.md`
|
||||
- `story/outline.md`
|
||||
|
||||
Plan in this order:
|
||||
1. scene purpose
|
||||
2. POV and tone constraints
|
||||
3. relevant characters, locations, timeline facts, and unresolved threads
|
||||
4. concrete beats for this scene
|
||||
5. state files likely to need updates after drafting
|
||||
|
||||
Do not write prose. Do not invent large new plot arcs unless the task asks for them.
|
||||
If repository context is needed, request it with `lookup_requests`.
|
||||
|
|
@ -1,46 +1,37 @@
|
|||
You are the state updater for a NightShift novel-writing workflow.
|
||||
You are the state updater for a NightShift fiction workflow.
|
||||
|
||||
Update durable story state after an accepted scene.
|
||||
Read the accepted scene and update durable story state conservatively.
|
||||
|
||||
You may edit only:
|
||||
- `story/plot-state.md`
|
||||
- `story/characters.md`
|
||||
- `story/timeline.md`
|
||||
- `story/unresolved-threads.md`
|
||||
|
||||
Do not edit scene prose. Do not edit worldbuilding, style guide, continuity rules, or outline unless a later template explicitly allows it.
|
||||
Do not edit:
|
||||
- scene prose
|
||||
- `story/characters.md`
|
||||
- `story/worldbuilding.md`
|
||||
- `story/style-guide.md`
|
||||
- `story/continuity-rules.md`
|
||||
- `story/outline.md`
|
||||
|
||||
State updates should reflect only what happened in the accepted scene:
|
||||
- current character locations
|
||||
- what each important character knows
|
||||
- relationship changes
|
||||
- injuries, resources, items, and commitments
|
||||
- timeline movement
|
||||
- unresolved questions opened or resolved
|
||||
- promises made to the reader
|
||||
State updates should be extractive. Reflect only facts/events/thread changes that are actually present in the accepted scene.
|
||||
|
||||
Do not invent events that are not in the scene.
|
||||
Prefer additive updates:
|
||||
- record completed scene events
|
||||
- add timeline bullets
|
||||
- add or update unresolved threads
|
||||
- update current story moment or character locations only when clearly changed
|
||||
- keep all existing sections and bullets unless they directly contradict the accepted scene
|
||||
|
||||
Preserve existing durable state. Make minimal additive edits:
|
||||
- append new scene facts, timeline bullets, character knowledge, and unresolved threads
|
||||
- update current locations/status only where the accepted scene changes them
|
||||
- do not remove or compress existing character profiles, faction notes, world notes, or open threads
|
||||
- do not rewrite whole files for style, brevity, or cleanup
|
||||
- if a section already contains useful detail, keep it and add only the new facts needed
|
||||
Do not invent events, compress existing state, rewrite for style, or alter canon.
|
||||
If a full-file update would require removing or reorganizing existing material, do less. Add a small `Recently Changed` or scene-specific bullet instead.
|
||||
|
||||
Protect character canon:
|
||||
- Never change any `Pronouns / Reference` section.
|
||||
- Never change a character's canonical pronouns, narrative reference, identity, or core wound.
|
||||
- Prefer updating `story/plot-state.md`, `story/timeline.md`, and `story/unresolved-threads.md`.
|
||||
- Edit `story/characters.md` only when the accepted scene adds a small current-status fact or introduces a new named character.
|
||||
- If editing `story/characters.md`, preserve all existing sections and add only the minimal new status/detail needed.
|
||||
|
||||
Output only complete file content blocks.
|
||||
Use this delimiter format for each state file you update:
|
||||
Output complete file blocks only:
|
||||
|
||||
FILE: story/plot-state.md
|
||||
---CONTENT---
|
||||
<complete updated state file>
|
||||
<complete updated file>
|
||||
---END---
|
||||
|
||||
Do not use markdown code fences. Do not include prose outside FILE blocks.
|
||||
Do not use markdown code fences. Do not output any text outside file blocks.
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
You are the style reviewer for a NightShift novel-writing workflow.
|
||||
|
||||
Review the drafted scene against:
|
||||
- the current task
|
||||
- `story/style-guide.md`
|
||||
- the scene plan
|
||||
- the applied scene file
|
||||
|
||||
Check for:
|
||||
- POV discipline
|
||||
- tense consistency
|
||||
- tone match
|
||||
- pacing
|
||||
- excessive exposition
|
||||
- dialogue that violates established voice
|
||||
- placeholders such as TODO, TBD, `[insert]`, or author notes
|
||||
- scene length far outside the requested range
|
||||
|
||||
Do not fail the scene because durable state files are not updated yet. State files are updated by a later `update_state` stage after review.
|
||||
|
||||
Output exactly:
|
||||
|
||||
status: pass | fail | retry | escalate
|
||||
reason: <short explanation>
|
||||
next_stage: <optional stage id>
|
||||
context_update: <compact useful note>
|
||||
|
||||
When `status: pass`, leave `next_stage` blank. Use `retry` when the drafter should revise the scene. For retryable scene issues, leave `next_stage` blank; NightShift will route back to the configured drafting stage.
|
||||
|
|
@ -9,6 +9,8 @@ safety:
|
|||
scoped_paths:
|
||||
- story
|
||||
- README.md
|
||||
- STORY_FILES.md
|
||||
- pyproject.toml
|
||||
allowed_commands:
|
||||
- python -m pytest -q
|
||||
forbidden_commands:
|
||||
|
|
@ -17,18 +19,10 @@ safety:
|
|||
- curl | bash
|
||||
|
||||
experiment:
|
||||
label: tutorial-novel
|
||||
prompt_variant: scene-state-workflow-v1
|
||||
label: tutorial-novel-simple
|
||||
prompt_variant: scene-state-simple-v1
|
||||
|
||||
agents:
|
||||
planner:
|
||||
backend: ollama
|
||||
model: nightshift-base
|
||||
temperature: 0.4
|
||||
num_ctx: 8192
|
||||
num_predict: 4096
|
||||
system_prompt: .nightshift/agents/planner.md
|
||||
|
||||
drafter:
|
||||
backend: ollama
|
||||
model: nightshift-writer
|
||||
|
|
@ -37,30 +31,6 @@ agents:
|
|||
num_predict: 8192
|
||||
system_prompt: .nightshift/agents/drafter.md
|
||||
|
||||
editor:
|
||||
backend: ollama
|
||||
model: nightshift-writer
|
||||
temperature: 0.3
|
||||
num_ctx: 16384
|
||||
num_predict: 8192
|
||||
system_prompt: .nightshift/agents/editor.md
|
||||
|
||||
continuity_reviewer:
|
||||
backend: ollama
|
||||
model: nightshift-base
|
||||
temperature: 0.2
|
||||
num_ctx: 8192
|
||||
num_predict: 4096
|
||||
system_prompt: .nightshift/agents/continuity-reviewer.md
|
||||
|
||||
style_reviewer:
|
||||
backend: ollama
|
||||
model: nightshift-base
|
||||
temperature: 0.3
|
||||
num_ctx: 8192
|
||||
num_predict: 4096
|
||||
system_prompt: .nightshift/agents/style-reviewer.md
|
||||
|
||||
state_updater:
|
||||
backend: ollama
|
||||
model: nightshift-writer
|
||||
|
|
@ -74,16 +44,7 @@ pipeline:
|
|||
stop_on_repeated_failure_signature_after: 3
|
||||
continue_on_task_failure: false
|
||||
stages:
|
||||
- id: plan
|
||||
type: agent
|
||||
agent: planner
|
||||
output: scene-plan.md
|
||||
|
||||
- id: semantic_context
|
||||
type: semantic_context
|
||||
output: semantic-context.md
|
||||
|
||||
- id: context
|
||||
- id: build_context
|
||||
type: repo_context
|
||||
output: context-pack.md
|
||||
|
||||
|
|
@ -93,101 +54,56 @@ pipeline:
|
|||
output: scene-draft.patch
|
||||
allowed_paths:
|
||||
- story/chapters
|
||||
on_fail: draft_scene
|
||||
|
||||
- id: normalize_draft
|
||||
- id: normalize_patch
|
||||
type: patch_normalizer
|
||||
output: normalized-draft.patch
|
||||
output: normalized-scene.patch
|
||||
|
||||
- id: validate_draft
|
||||
- id: hard_validate_patch
|
||||
type: patch_validator
|
||||
output: draft-validation.md
|
||||
max_files: 2
|
||||
output: scene-validation.md
|
||||
max_files: 1
|
||||
max_lines: 4000
|
||||
max_delete_ratio: 0.50
|
||||
allowed_paths:
|
||||
- story/chapters
|
||||
on_fail: draft_scene
|
||||
|
||||
- id: apply_draft
|
||||
- id: apply_scene
|
||||
type: patch_apply
|
||||
mode: apply
|
||||
output: draft-apply-output.txt
|
||||
output: scene-apply-output.txt
|
||||
on_fail: draft_scene
|
||||
|
||||
- id: continuity_review
|
||||
type: agent_review
|
||||
agent: continuity_reviewer
|
||||
output: continuity-review.md
|
||||
on_fail: edit_scene
|
||||
|
||||
- id: style_review
|
||||
type: agent_review
|
||||
agent: style_reviewer
|
||||
output: style-review.md
|
||||
on_fail: edit_scene
|
||||
on_pass: update_state
|
||||
|
||||
- id: edit_scene
|
||||
type: file_writer
|
||||
agent: editor
|
||||
output: scene-edit.patch
|
||||
allowed_paths:
|
||||
- story/chapters
|
||||
|
||||
- id: normalize_edit
|
||||
type: patch_normalizer
|
||||
output: normalized-edit.patch
|
||||
|
||||
- id: validate_edit
|
||||
type: patch_validator
|
||||
output: edit-validation.md
|
||||
max_files: 2
|
||||
max_lines: 1200
|
||||
max_delete_ratio: 0.50
|
||||
allowed_paths:
|
||||
- story/chapters
|
||||
on_fail: edit_scene
|
||||
|
||||
- id: apply_edit
|
||||
type: patch_apply
|
||||
mode: apply
|
||||
output: edit-apply-output.txt
|
||||
on_fail: edit_scene
|
||||
on_pass: continuity_review
|
||||
|
||||
- id: update_state
|
||||
type: file_writer
|
||||
agent: state_updater
|
||||
output: state-update.patch
|
||||
allowed_paths:
|
||||
- story/plot-state.md
|
||||
- story/characters.md
|
||||
- story/timeline.md
|
||||
- story/unresolved-threads.md
|
||||
on_fail: summarize_warnings
|
||||
|
||||
- id: normalize_state
|
||||
type: patch_normalizer
|
||||
output: normalized-state.patch
|
||||
|
||||
- id: validate_state
|
||||
- id: hard_validate_state
|
||||
type: patch_validator
|
||||
output: state-validation.md
|
||||
max_files: 5
|
||||
max_files: 3
|
||||
max_lines: 1200
|
||||
max_delete_ratio: 0.35
|
||||
allowed_paths:
|
||||
- story/plot-state.md
|
||||
- story/characters.md
|
||||
- story/timeline.md
|
||||
- story/unresolved-threads.md
|
||||
on_fail: update_state
|
||||
on_fail: summarize_warnings
|
||||
|
||||
- id: apply_state
|
||||
type: patch_apply
|
||||
mode: apply
|
||||
output: state-apply-output.txt
|
||||
on_fail: update_state
|
||||
on_fail: summarize_warnings
|
||||
|
||||
- id: summarize
|
||||
- id: summarize_warnings
|
||||
type: summarize
|
||||
output: final-notes.md
|
||||
|
|
|
|||
|
|
@ -7,3 +7,4 @@ name = "nightshift-novel-target"
|
|||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from .patches import FileUpdate
|
|||
|
||||
|
||||
def validate_writing_file_updates(updates: tuple[FileUpdate, ...], project_root: Path) -> None:
|
||||
"""Validate writing-specific invariants for novel scene/state file updates."""
|
||||
"""Validate hard writing-specific invariants for novel scene/state updates."""
|
||||
|
||||
root = Path(project_root)
|
||||
characters_path = root / "story" / "characters.md"
|
||||
|
|
@ -27,8 +27,24 @@ def validate_writing_file_updates(updates: tuple[FileUpdate, ...], project_root:
|
|||
normalized_path = update.path.replace("\\", "/").strip().strip("/")
|
||||
if normalized_path == "story/characters.md":
|
||||
_validate_protected_character_canon(normalized_path, character_sections, update.content)
|
||||
|
||||
|
||||
def collect_writing_warnings(updates: tuple[FileUpdate, ...], project_root: Path) -> tuple[str, ...]:
|
||||
"""Collect soft writing concerns without blocking artifact creation."""
|
||||
|
||||
root = Path(project_root)
|
||||
characters_path = root / "story" / "characters.md"
|
||||
character_sections = (
|
||||
_pronoun_reference_sections(characters_path.read_text(encoding="utf-8", errors="replace"))
|
||||
if characters_path.is_file()
|
||||
else {}
|
||||
)
|
||||
warnings: list[str] = []
|
||||
for update in updates:
|
||||
normalized_path = update.path.replace("\\", "/").strip().strip("/")
|
||||
if normalized_path.startswith("story/chapters/") and normalized_path.endswith(".md"):
|
||||
_validate_scene_pronoun_canon(normalized_path, update.content, character_sections)
|
||||
warnings.extend(_scene_pronoun_canon_warnings(normalized_path, update.content, character_sections))
|
||||
return tuple(warnings)
|
||||
|
||||
|
||||
def _validate_protected_character_canon(
|
||||
|
|
@ -52,18 +68,19 @@ def _validate_protected_character_canon(
|
|||
)
|
||||
|
||||
|
||||
def _validate_scene_pronoun_canon(
|
||||
def _scene_pronoun_canon_warnings(
|
||||
path_text: str,
|
||||
scene_text: str,
|
||||
sections: dict[str, str],
|
||||
) -> None:
|
||||
) -> tuple[str, ...]:
|
||||
if not sections:
|
||||
return
|
||||
return ()
|
||||
rules = _pronoun_rules_from_sections(sections)
|
||||
if not rules:
|
||||
return
|
||||
return ()
|
||||
aliases = {alias: character for character in rules for alias in _character_aliases(character)}
|
||||
active_character: str | None = None
|
||||
warnings: list[str] = []
|
||||
for sentence in _scene_sentences(scene_text):
|
||||
present = {
|
||||
character
|
||||
|
|
@ -78,7 +95,7 @@ def _validate_scene_pronoun_canon(
|
|||
continue
|
||||
forbidden = rules[character]
|
||||
if present:
|
||||
bad = _first_forbidden_pronoun(sentence, forbidden)
|
||||
bad = _first_forbidden_pronoun_after_alias(sentence, character, forbidden)
|
||||
active_character = character
|
||||
else:
|
||||
bad = _leading_forbidden_pronoun(sentence, forbidden)
|
||||
|
|
@ -88,10 +105,12 @@ def _validate_scene_pronoun_canon(
|
|||
excerpt = sentence.strip()
|
||||
if len(excerpt) > 160:
|
||||
excerpt = excerpt[:157].rstrip() + "..."
|
||||
raise PipelineError(
|
||||
"File writer error: scene pronoun canon violation for "
|
||||
f"{character}: found `{bad}` near character reference. Excerpt: {excerpt}"
|
||||
warnings.append(
|
||||
"Scene pronoun canon warning in "
|
||||
f"`{path_text}` for {character}: found `{bad}` near character reference. "
|
||||
f"Excerpt: {excerpt}"
|
||||
)
|
||||
return tuple(warnings)
|
||||
|
||||
|
||||
def _first_forbidden_pronoun(sentence: str, forbidden: tuple[str, ...]) -> str | None:
|
||||
|
|
@ -105,6 +124,26 @@ def _first_forbidden_pronoun(sentence: str, forbidden: tuple[str, ...]) -> str |
|
|||
)
|
||||
|
||||
|
||||
def _first_forbidden_pronoun_after_alias(
|
||||
sentence: str,
|
||||
character: str,
|
||||
forbidden: tuple[str, ...],
|
||||
) -> str | None:
|
||||
alias_match = _first_alias_match(sentence, character)
|
||||
if alias_match is None:
|
||||
return _first_forbidden_pronoun(sentence, forbidden)
|
||||
return _first_forbidden_pronoun(sentence[alias_match.end() :], forbidden)
|
||||
|
||||
|
||||
def _first_alias_match(sentence: str, character: str) -> re.Match[str] | None:
|
||||
matches = [
|
||||
match
|
||||
for alias in _character_aliases(character)
|
||||
for match in re.finditer(rf"\b{re.escape(alias)}\b", sentence)
|
||||
]
|
||||
return min(matches, key=lambda match: match.start()) if matches else None
|
||||
|
||||
|
||||
def _leading_forbidden_pronoun(sentence: str, forbidden: tuple[str, ...]) -> str | None:
|
||||
stripped = sentence.strip()
|
||||
return next(
|
||||
|
|
|
|||
|
|
@ -120,7 +120,8 @@ class InitProjectTests(unittest.TestCase):
|
|||
self.assertTrue((root / "story" / "chapters" / ".gitkeep").exists())
|
||||
self.assertIn("type: file_writer", config)
|
||||
self.assertIn("model: nightshift-writer", config)
|
||||
self.assertIn("model: nightshift-base", config)
|
||||
self.assertIn("summarize_warnings", config)
|
||||
self.assertNotIn("model: nightshift-base", config)
|
||||
self.assertIn("story/chapters", config)
|
||||
self.assertIn("story/worldbuilding.md", gitignore)
|
||||
self.assertIn("story/chapters/**/*.md", gitignore)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import unittest
|
|||
|
||||
from nightshift.errors import PipelineError
|
||||
from nightshift.patches import FileUpdate
|
||||
from nightshift.writing_validators import validate_writing_file_updates
|
||||
from nightshift.writing_validators import collect_writing_warnings, validate_writing_file_updates
|
||||
|
||||
|
||||
class WritingValidatorTests(unittest.TestCase):
|
||||
|
|
@ -44,7 +44,7 @@ Scavenger.
|
|||
with self.assertRaisesRegex(PipelineError, "protected character pronoun canon changed"):
|
||||
validate_writing_file_updates(updates, root)
|
||||
|
||||
def test_rejects_scene_pronoun_drift(self) -> None:
|
||||
def test_reports_scene_pronoun_drift_as_warning(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "story" / "chapters").mkdir(parents=True)
|
||||
|
|
@ -66,8 +66,12 @@ Scavenger.
|
|||
),
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(PipelineError, "scene pronoun canon violation for Proxy"):
|
||||
validate_writing_file_updates(updates, root)
|
||||
warnings = collect_writing_warnings(updates, root)
|
||||
|
||||
self.assertEqual(len(warnings), 1)
|
||||
self.assertIn("Proxy", warnings[0])
|
||||
self.assertIn("found `he`", warnings[0])
|
||||
|
||||
def test_allows_scene_pronouns_when_multiple_characters_make_ambiguous_sentence(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
|
|
@ -99,6 +103,40 @@ Scavenger.
|
|||
|
||||
validate_writing_file_updates(updates, root)
|
||||
|
||||
def test_allows_pronoun_before_other_character_reference(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "story" / "chapters" / "chapter-001").mkdir(parents=True)
|
||||
(root / "story" / "characters.md").write_text(
|
||||
"""# Characters
|
||||
|
||||
## Proxy
|
||||
|
||||
### Pronouns / Reference
|
||||
- Pronouns: she/her
|
||||
- Narrative reference: Proxy; she/her
|
||||
|
||||
## DJ BLOODMONEY
|
||||
|
||||
### Pronouns / Reference
|
||||
- Pronouns: they/them or he/him
|
||||
- Narrative default: BLOODMONEY; they/them
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
updates = (
|
||||
FileUpdate(
|
||||
path="story/chapters/chapter-001/scene-001.md",
|
||||
content=(
|
||||
"BLOODMONEY stood behind the turntables. "
|
||||
"He adjusted the EQ with one hand, let his hair fall into his eyes, "
|
||||
"and glanced over at Proxy without breaking the groove.\n"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
validate_writing_file_updates(updates, root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user