1317 lines
54 KiB
Python
1317 lines
54 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from django.conf import settings
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
from django.utils.text import slugify
|
|
|
|
from control_plane.authoring.models import (
|
|
DocumentAuthority,
|
|
DocumentType,
|
|
PromptVersion,
|
|
SceneContextCitation,
|
|
SceneDraftStatus,
|
|
SceneIdeation,
|
|
StandaloneScene,
|
|
Work,
|
|
text_sha256,
|
|
)
|
|
from control_plane.authoring.prompts import (
|
|
SCENE_IDEA_TYPES,
|
|
SCENE_IDEATION_COMPACT_TEMPLATE,
|
|
SCENE_IDEATION_SYSTEM,
|
|
SCENE_IDEATION_TEMPLATE,
|
|
STANDALONE_SCENE_PLAN_SYSTEM,
|
|
STANDALONE_SCENE_PLAN_TEMPLATE,
|
|
STANDALONE_SCENE_PROSE_SYSTEM,
|
|
STANDALONE_SCENE_PROSE_TEMPLATE,
|
|
STANDALONE_SCENE_REVIEW_SYSTEM,
|
|
STANDALONE_SCENE_REVIEW_TEMPLATE,
|
|
)
|
|
from control_plane.authoring.scene_context import build_scene_context_pack
|
|
from control_plane.authoring.sources import register_source
|
|
from control_plane.authoring.state_management import (
|
|
build_contract_requirements,
|
|
evidence_is_present,
|
|
json_sha256,
|
|
)
|
|
from control_plane.authoring.streaming import ResumableDraftWriter, atomic_write_text, word_count
|
|
from model_router.providers import extract_json_object
|
|
from model_router.router import ModelCapability, ModelRequestContract, ModelRouter
|
|
|
|
|
|
def render_authoring_prompt(
|
|
purpose: str,
|
|
default_system: str,
|
|
default_template: str,
|
|
**values: Any,
|
|
) -> tuple[str, str | None]:
|
|
stored = PromptVersion.objects.filter(purpose=purpose, is_active=True).first()
|
|
system = stored.system_text if stored else default_system
|
|
template = stored.user_template if stored else default_template
|
|
return f"{system}\n\n{template.format(**values)}", str(stored.id) if stored else None
|
|
|
|
|
|
class SceneIdeationService:
|
|
def __init__(self, router: ModelRouter) -> None:
|
|
self.router = router
|
|
|
|
def propose(
|
|
self,
|
|
*,
|
|
work: Work,
|
|
target_book: str,
|
|
focus: str = "",
|
|
candidate_count: int = 10,
|
|
scene_types: list[str] | None = None,
|
|
authorities: list[str] | None = None,
|
|
pinned_document_keys: list[str] | None = None,
|
|
governing_document_keys: list[str] | None = None,
|
|
detail_level: str = "full",
|
|
model_hint: str | None = None,
|
|
book_state=None,
|
|
) -> SceneIdeation:
|
|
candidate_count = int(candidate_count)
|
|
if not 1 <= candidate_count <= 12:
|
|
raise ValueError("candidate_count must be between 1 and 12")
|
|
target_book = target_book.strip()
|
|
if not target_book:
|
|
raise ValueError("target_book is required")
|
|
if len(target_book) > 160:
|
|
raise ValueError("target_book must be 160 characters or fewer")
|
|
requested_scene_types = list(dict.fromkeys(scene_types or SCENE_IDEA_TYPES))
|
|
invalid_scene_types = sorted(set(requested_scene_types) - set(SCENE_IDEA_TYPES))
|
|
if invalid_scene_types:
|
|
raise ValueError("unsupported scene types: " + ", ".join(invalid_scene_types))
|
|
detail_level = detail_level.strip().lower()
|
|
if detail_level not in {"full", "compact"}:
|
|
raise ValueError("detail_level must be full or compact")
|
|
authorities = authorities or [DocumentAuthority.CANON, DocumentAuthority.PLANNING]
|
|
if book_state:
|
|
if book_state.work_id != work.id:
|
|
raise ValueError("book state must belong to the ideation work")
|
|
if book_state.status != "approved":
|
|
raise ValueError("book state must be approved before ideation")
|
|
focus = focus.strip()
|
|
query = f"{target_book} {focus}".strip() or (
|
|
f"{work.title} unresolved choices open threads relationship pressure "
|
|
"character agency promises boundaries consequences"
|
|
)
|
|
pack, _ranked = build_scene_context_pack(
|
|
work=work,
|
|
query=query,
|
|
authorities=authorities,
|
|
pinned_document_keys=pinned_document_keys,
|
|
governing_document_keys=governing_document_keys,
|
|
limit=32,
|
|
max_chars=150000,
|
|
)
|
|
if book_state:
|
|
book_packet = {
|
|
"book_state_id": str(book_state.id),
|
|
"version": book_state.version,
|
|
"sha256": book_state.sha256,
|
|
"content": book_state.content,
|
|
}
|
|
pack["rendered_context"] = (
|
|
"APPROVED BOOK STATE PLANNING CONTRACT\n"
|
|
+ json.dumps(book_packet, ensure_ascii=False, indent=2)
|
|
+ "\n\n"
|
|
+ pack["rendered_context"]
|
|
)
|
|
pack["book_state_id"] = str(book_state.id)
|
|
pack["book_state_sha256"] = book_state.sha256
|
|
pack["sha256"] = json_sha256(
|
|
{key: value for key, value in pack.items() if key != "sha256"}
|
|
)
|
|
prompt, prompt_version_id = render_authoring_prompt(
|
|
"SCENE_IDEATION",
|
|
SCENE_IDEATION_SYSTEM,
|
|
(
|
|
SCENE_IDEATION_COMPACT_TEMPLATE
|
|
if detail_level == "compact"
|
|
else SCENE_IDEATION_TEMPLATE
|
|
),
|
|
candidate_count=candidate_count,
|
|
work_title=work.title,
|
|
target_book=target_book,
|
|
focus=focus or "Open scan for unspent, evidence-backed scene opportunities.",
|
|
context=pack["rendered_context"],
|
|
scene_types=json.dumps(
|
|
{
|
|
key: SCENE_IDEA_TYPES[key]
|
|
for key in requested_scene_types
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
)
|
|
story = getattr(work, "story_project", None)
|
|
response = self.router.complete(
|
|
ModelRequestContract(
|
|
purpose=ModelCapability.STORY_PLANNING,
|
|
prompt=prompt,
|
|
model_hint=model_hint,
|
|
token_budget=(
|
|
max(3000, min(8000, candidate_count * 700))
|
|
if detail_level == "compact"
|
|
else max(5000, min(12000, candidate_count * 1200))
|
|
),
|
|
project=story.project if story else None,
|
|
)
|
|
)
|
|
candidates = self._normalize_candidates(
|
|
extract_json_object(response.content),
|
|
candidate_count=candidate_count,
|
|
citation_ids={item["id"] for item in pack["citations"]},
|
|
allowed_scene_types=set(requested_scene_types),
|
|
compact=detail_level == "compact",
|
|
)
|
|
return SceneIdeation.objects.create(
|
|
work=work,
|
|
book_state=book_state,
|
|
target_book=target_book,
|
|
focus=focus,
|
|
requested_scene_types=requested_scene_types,
|
|
candidate_count=candidate_count,
|
|
authorities=authorities,
|
|
pinned_document_keys=pinned_document_keys or [],
|
|
context_pack=pack,
|
|
context_pack_sha256=pack["sha256"],
|
|
candidates=candidates,
|
|
generation_metadata={
|
|
"model": response.model,
|
|
"prompt_version_id": prompt_version_id,
|
|
"prompt_sha256": text_sha256(prompt),
|
|
"response_sha256": text_sha256(response.content),
|
|
"context_pack_sha256": pack["sha256"],
|
|
"detail_level": detail_level,
|
|
},
|
|
)
|
|
|
|
@transaction.atomic
|
|
def select_candidate(
|
|
self,
|
|
ideation: SceneIdeation,
|
|
*,
|
|
candidate_id: str,
|
|
target_words: int | None = None,
|
|
book_chapter_key: str | None = None,
|
|
) -> tuple[StandaloneScene, bool]:
|
|
ideation = SceneIdeation.objects.select_for_update().select_related("work").get(
|
|
id=ideation.id
|
|
)
|
|
candidates = list(ideation.candidates or [])
|
|
candidate = next(
|
|
(item for item in candidates if item.get("candidate_id") == candidate_id),
|
|
None,
|
|
)
|
|
if candidate is None:
|
|
raise ValueError("idea candidate not found")
|
|
selected_scene_id = candidate.get("selected_scene_id")
|
|
if selected_scene_id:
|
|
return StandaloneScene.objects.get(id=selected_scene_id), False
|
|
if ideation.book_state_id and not str(book_chapter_key or "").strip():
|
|
raise ValueError("book_chapter_key is required for book-state ideation selection")
|
|
constraints = list(candidate.get("constraints") or [])
|
|
scope_fit = str(candidate.get("scope_fit") or "").strip()
|
|
if ideation.target_book and scope_fit:
|
|
constraints.append(f"Placement scope: {ideation.target_book}. {scope_fit}")
|
|
scene = StandaloneSceneService(self.router).create(
|
|
work=ideation.work,
|
|
title=candidate["title"],
|
|
brief=candidate["brief"],
|
|
target_words=int(target_words or candidate.get("target_words") or 1800),
|
|
constraints=constraints,
|
|
forbidden_events=candidate.get("forbidden_events") or [],
|
|
boundary_constraints=candidate.get("boundary_constraints") or [],
|
|
book_state=ideation.book_state,
|
|
book_chapter_key=book_chapter_key,
|
|
)
|
|
candidate["selected_scene_id"] = str(scene.id)
|
|
ideation.candidates = candidates
|
|
ideation.save(update_fields=["candidates", "updated_at"])
|
|
return scene, True
|
|
|
|
@staticmethod
|
|
def _normalize_candidates(
|
|
data: dict[str, Any],
|
|
*,
|
|
candidate_count: int,
|
|
citation_ids: set[str],
|
|
allowed_scene_types: set[str] | None = None,
|
|
compact: bool = False,
|
|
) -> list[dict[str, Any]]:
|
|
allowed_scene_types = allowed_scene_types or set(SCENE_IDEA_TYPES)
|
|
raw_candidates = data.get("candidates")
|
|
if not isinstance(raw_candidates, list) or len(raw_candidates) != candidate_count:
|
|
raise ValueError(f"ideation must return exactly {candidate_count} candidates")
|
|
normalized = []
|
|
for index, raw in enumerate(raw_candidates, start=1):
|
|
if not isinstance(raw, dict):
|
|
raise ValueError("each idea candidate must be an object")
|
|
title = str(raw.get("title") or "").strip()
|
|
brief = str(raw.get("brief") or "").strip()
|
|
purpose = str(raw.get("purpose") or "").strip()
|
|
opportunity = str(raw.get("opportunity") or "").strip()
|
|
scene_type = str(raw.get("scene_type") or "").strip()
|
|
scope_fit = str(raw.get("scope_fit") or "").strip()
|
|
type_fit = str(raw.get("type_fit") or "").strip()
|
|
raw_citations = raw.get("citations")
|
|
if not isinstance(raw_citations, list):
|
|
raise ValueError("each idea candidate citations value must be a list")
|
|
citations = list(
|
|
dict.fromkeys(str(value).strip() for value in raw_citations if str(value).strip())
|
|
)
|
|
invalid_citations = sorted(set(citations) - citation_ids)
|
|
if not title or not brief or not opportunity or (not compact and not purpose):
|
|
raise ValueError(
|
|
"each idea candidate requires title, brief, opportunity, "
|
|
"and full-detail purpose"
|
|
)
|
|
if scene_type not in allowed_scene_types:
|
|
raise ValueError(
|
|
"each idea candidate requires a requested scene_type: "
|
|
+ ", ".join(sorted(allowed_scene_types))
|
|
)
|
|
if not compact and (not scope_fit or not type_fit):
|
|
raise ValueError("each idea candidate requires scope_fit and type_fit")
|
|
if not citations or invalid_citations:
|
|
raise ValueError("each idea candidate must use only supplied citation IDs")
|
|
future_opportunities = _string_list(raw.get("future_opportunities"))
|
|
if not future_opportunities:
|
|
raise ValueError("each idea candidate requires future_opportunities")
|
|
candidate = {
|
|
"candidate_id": f"idea-{index:02d}",
|
|
"title": title,
|
|
"brief": brief,
|
|
"scene_type": scene_type,
|
|
"citations": citations,
|
|
"opportunity": opportunity,
|
|
"future_opportunities": future_opportunities,
|
|
}
|
|
if not compact:
|
|
proposed_words = int(raw.get("target_words") or 1800)
|
|
candidate.update(
|
|
{
|
|
"purpose": purpose,
|
|
"placement": str(raw.get("placement") or "").strip(),
|
|
"pov_character": str(raw.get("pov_character") or "").strip(),
|
|
"type_fit": type_fit,
|
|
"scope_fit": scope_fit,
|
|
"prerequisites": _string_list(raw.get("prerequisites")),
|
|
"target_words": min(10000, max(300, proposed_words)),
|
|
"constraints": _string_list(raw.get("constraints")),
|
|
"forbidden_events": _string_list(raw.get("forbidden_events")),
|
|
"boundary_constraints": _string_list(raw.get("boundary_constraints")),
|
|
"continuity_questions": _string_list(raw.get("continuity_questions")),
|
|
"risks": _string_list(raw.get("risks")),
|
|
}
|
|
)
|
|
normalized.append(candidate)
|
|
required_distinct = min(candidate_count, len(allowed_scene_types))
|
|
distinct_types = {candidate["scene_type"] for candidate in normalized}
|
|
if len(distinct_types) < required_distinct:
|
|
raise ValueError(
|
|
f"ideation requires {required_distinct} distinct scene types; "
|
|
f"received {len(distinct_types)}"
|
|
)
|
|
return normalized
|
|
|
|
|
|
def _string_list(value: Any) -> list[str]:
|
|
if not isinstance(value, list):
|
|
return []
|
|
return [str(item).strip() for item in value if str(item).strip()]
|
|
|
|
|
|
def render_scene_ideation_markdown(ideation: SceneIdeation) -> str:
|
|
context_pack = ideation.context_pack or {}
|
|
candidates = ideation.candidates or []
|
|
metadata = ideation.generation_metadata or {}
|
|
selected = [
|
|
(candidate.get("candidate_id"), candidate.get("selected_scene_id"))
|
|
for candidate in candidates
|
|
if candidate.get("selected_scene_id")
|
|
]
|
|
lines = [
|
|
f"# Scene Ideas: {_single_line(ideation.work.title)}",
|
|
"",
|
|
"## Review Status",
|
|
"",
|
|
]
|
|
if selected:
|
|
for candidate_id, scene_id in selected:
|
|
lines.append(f"- `{candidate_id}` selected as scene `{scene_id}`.")
|
|
else:
|
|
lines.extend(
|
|
[
|
|
"- No candidate has been selected.",
|
|
"- No scene was created by this proposal.",
|
|
]
|
|
)
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"## Proposal Record",
|
|
"",
|
|
f"- Ideation ID: `{ideation.id}`",
|
|
f"- Created: `{ideation.created_at.isoformat()}`",
|
|
f"- Series: `{ideation.work.series.slug}`",
|
|
f"- Work: `{ideation.work.slug}`",
|
|
f"- Target book: `{ideation.target_book or 'not recorded'}`",
|
|
f"- Candidate count: `{ideation.candidate_count}`",
|
|
f"- Requested scene types: {_code_list(ideation.requested_scene_types)}",
|
|
f"- Governing documents: {_code_list(context_pack.get('governing_document_keys') or [])}",
|
|
f"- Authorities: {_code_list(ideation.authorities)}",
|
|
f"- Focus: {_single_line(ideation.focus) or '(open scan)'}",
|
|
f"- Context pack SHA-256: `{ideation.context_pack_sha256}`",
|
|
f"- Model: `{_single_line(metadata.get('model')) or 'unknown'}`",
|
|
f"- Prompt version ID: `{metadata.get('prompt_version_id') or 'default'}`",
|
|
f"- Prompt SHA-256: `{metadata.get('prompt_sha256') or ''}`",
|
|
f"- Response SHA-256: `{metadata.get('response_sha256') or ''}`",
|
|
]
|
|
)
|
|
if ideation.pinned_document_keys:
|
|
lines.extend(["", "### Pinned Documents", ""])
|
|
_append_markdown_list(lines, ideation.pinned_document_keys)
|
|
|
|
for candidate in candidates:
|
|
candidate_id = _single_line(candidate.get("candidate_id"))
|
|
title = _single_line(candidate.get("title"))
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"---",
|
|
"",
|
|
f"## {candidate_id}: {title}",
|
|
"",
|
|
f"**POV:** {_single_line(candidate.get('pov_character')) or 'Unspecified'} ",
|
|
f"**Scene type:** `{candidate.get('scene_type') or 'not recorded'}` ",
|
|
f"**Target:** {candidate.get('target_words') or 1800:,} words ",
|
|
f"**Selected scene:** `{candidate.get('selected_scene_id') or 'not selected'}`",
|
|
]
|
|
)
|
|
_append_markdown_paragraph(lines, "Brief", candidate.get("brief"))
|
|
_append_markdown_paragraph(lines, "Purpose", candidate.get("purpose"))
|
|
_append_markdown_paragraph(lines, "Placement", candidate.get("placement"))
|
|
_append_markdown_paragraph(lines, "Book Scope Fit", candidate.get("scope_fit"))
|
|
_append_markdown_section(lines, "Prerequisites", candidate.get("prerequisites"))
|
|
_append_markdown_paragraph(lines, "Scene Type Fit", candidate.get("type_fit"))
|
|
_append_markdown_paragraph(lines, "Opportunity Spent", candidate.get("opportunity"))
|
|
_append_markdown_section(
|
|
lines,
|
|
"Future Opportunities Created",
|
|
candidate.get("future_opportunities"),
|
|
empty="Not recorded; this proposal predates future-opportunity tracking.",
|
|
)
|
|
_append_markdown_section(lines, "Constraints", candidate.get("constraints"))
|
|
_append_markdown_section(lines, "Forbidden Events", candidate.get("forbidden_events"))
|
|
_append_markdown_section(
|
|
lines, "Boundary Constraints", candidate.get("boundary_constraints")
|
|
)
|
|
_append_markdown_section(
|
|
lines, "Continuity Questions", candidate.get("continuity_questions")
|
|
)
|
|
_append_markdown_section(lines, "Risks", candidate.get("risks"))
|
|
_append_markdown_section(lines, "Citations", candidate.get("citations"), code=True)
|
|
|
|
lines.extend(["", "---", "", "## Frozen Citation Index", ""])
|
|
for citation in context_pack.get("citations") or []:
|
|
citation_id = _single_line(citation.get("id"))
|
|
document_key = _single_line(citation.get("document_key"))
|
|
start_line = citation.get("start_line")
|
|
end_line = citation.get("end_line")
|
|
line_range = str(start_line) if start_line == end_line else f"{start_line}-{end_line}"
|
|
lines.extend(
|
|
[
|
|
f"### {citation_id}",
|
|
"",
|
|
f"- Document: `{document_key}`",
|
|
f"- Title: {_single_line(citation.get('document_title'))}",
|
|
f"- Version: `{citation.get('document_version')}`",
|
|
f"- Authority: `{citation.get('authority')}`",
|
|
f"- Lines: `{line_range}`",
|
|
f"- Passage SHA-256: `{citation.get('sha256')}`",
|
|
f"- Retrieval score: `{citation.get('score')}`",
|
|
f"- Retrieval reason: {_single_line(citation.get('reason'))}",
|
|
"",
|
|
]
|
|
)
|
|
return "\n".join(lines).rstrip() + "\n"
|
|
|
|
|
|
def render_scene_ideation_compact_markdown(ideation: SceneIdeation) -> str:
|
|
lines = [
|
|
f"# Scene Ideas: {_single_line(ideation.work.title)}",
|
|
"",
|
|
f"- Ideation ID: `{ideation.id}`",
|
|
f"- Target book: `{ideation.target_book or 'not recorded'}`",
|
|
f"- Focus: {_single_line(ideation.focus) or '(open scan)'}",
|
|
]
|
|
for candidate in ideation.candidates or []:
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"---",
|
|
"",
|
|
(
|
|
f"## {_single_line(candidate.get('candidate_id'))}: "
|
|
f"{_single_line(candidate.get('title'))}"
|
|
),
|
|
]
|
|
)
|
|
_append_markdown_paragraph(lines, "Brief", candidate.get("brief"))
|
|
_append_markdown_paragraph(lines, "Opportunity Spent", candidate.get("opportunity"))
|
|
_append_markdown_section(
|
|
lines,
|
|
"Future Opportunities Created",
|
|
candidate.get("future_opportunities"),
|
|
)
|
|
lines.extend(
|
|
[
|
|
"",
|
|
f"### Evaluation: {_single_line(candidate.get('evaluation'))}".rstrip(),
|
|
"",
|
|
f"### Feedback: {_single_line(candidate.get('feedback'))}".rstrip(),
|
|
]
|
|
)
|
|
return "\n".join(lines).rstrip() + "\n"
|
|
|
|
|
|
def export_scene_ideation_markdown(
|
|
ideation: SceneIdeation, output: Path, *, compact: bool = False
|
|
) -> Path:
|
|
content = (
|
|
render_scene_ideation_compact_markdown(ideation)
|
|
if compact
|
|
else render_scene_ideation_markdown(ideation)
|
|
)
|
|
atomic_write_text(output, content)
|
|
return output
|
|
|
|
|
|
def _single_line(value: Any) -> str:
|
|
return " ".join(str(value or "").splitlines()).strip()
|
|
|
|
|
|
def _code_list(values: list[Any]) -> str:
|
|
return ", ".join(f"`{_single_line(value)}`" for value in values) or "(none)"
|
|
|
|
|
|
def _append_markdown_paragraph(lines: list[str], heading: str, value: Any) -> None:
|
|
text = str(value or "").strip()
|
|
if text:
|
|
lines.extend(["", f"### {heading}", "", text])
|
|
|
|
|
|
def _append_markdown_section(
|
|
lines: list[str],
|
|
heading: str,
|
|
values: Any,
|
|
*,
|
|
code: bool = False,
|
|
empty: str = "",
|
|
) -> None:
|
|
items = _string_list(values)
|
|
if not items and not empty:
|
|
return
|
|
lines.extend(["", f"### {heading}", ""])
|
|
_append_markdown_list(lines, items or [empty], code=code)
|
|
|
|
|
|
def _append_markdown_list(lines: list[str], values: list[Any], *, code: bool = False) -> None:
|
|
for value in values:
|
|
text = str(value).strip()
|
|
if not text:
|
|
continue
|
|
if code:
|
|
text = f"`{_single_line(text)}`"
|
|
else:
|
|
text = text.replace("\n", "\n ")
|
|
lines.append(f"- {text}")
|
|
|
|
|
|
class StandaloneSceneService:
|
|
def __init__(self, router: ModelRouter) -> None:
|
|
self.router = router
|
|
self.writer = ResumableDraftWriter(router)
|
|
|
|
@transaction.atomic
|
|
def create(
|
|
self,
|
|
*,
|
|
work: Work,
|
|
title: str,
|
|
brief: str,
|
|
target_words: int = 1800,
|
|
constraints: list[str] | None = None,
|
|
forbidden_events: list[str] | None = None,
|
|
boundary_constraints: list[str] | None = None,
|
|
book_state=None,
|
|
book_chapter_key: str | None = None,
|
|
) -> StandaloneScene:
|
|
work = Work.objects.select_for_update().get(pk=work.pk)
|
|
if not 300 <= int(target_words) <= 10000:
|
|
raise ValueError("target_words must be between 300 and 10000")
|
|
chapter_key = str(book_chapter_key or "").strip()
|
|
if bool(book_state) != bool(chapter_key):
|
|
raise ValueError("book_state and book_chapter_key must be provided together")
|
|
if book_state:
|
|
if book_state.work_id != work.id:
|
|
raise ValueError("book state must belong to the scene work")
|
|
if str(book_state.status).upper() != "APPROVED":
|
|
raise ValueError("book state must be approved before binding a scene")
|
|
latest_run = book_state.runs.order_by("-created_at").first()
|
|
if latest_run and latest_run.status == "complete":
|
|
raise ValueError("start a new book run before revising a completed chapter")
|
|
self._book_chapter(book_state.content or {}, chapter_key)
|
|
scene_key = slugify(title)[:200] or "scene"
|
|
lineage = StandaloneScene.objects.filter(work=work, scene_key=scene_key)
|
|
if book_state:
|
|
chapter_scenes = StandaloneScene.objects.filter(
|
|
work=work,
|
|
book_state=book_state,
|
|
book_chapter_key=chapter_key,
|
|
)
|
|
other_lineage = chapter_scenes.exclude(scene_key=scene_key).exists()
|
|
if other_lineage:
|
|
raise ValueError("book state chapter already has a different scene lineage")
|
|
lineage = lineage.filter(book_state=book_state, book_chapter_key=chapter_key)
|
|
else:
|
|
lineage = lineage.filter(book_state__isnull=True)
|
|
latest = lineage.order_by("-revision").first()
|
|
story = getattr(work, "story_project", None)
|
|
return StandaloneScene.objects.create(
|
|
work=work,
|
|
story=story,
|
|
parent=latest,
|
|
scene_key=scene_key,
|
|
revision=(latest.revision + 1) if latest else 1,
|
|
title=title.strip(),
|
|
brief=brief.strip(),
|
|
target_words=int(target_words),
|
|
constraints=constraints or [],
|
|
forbidden_events=forbidden_events or [],
|
|
boundary_constraints=boundary_constraints or [],
|
|
book_state=book_state,
|
|
book_chapter_key=chapter_key,
|
|
)
|
|
|
|
def plan(
|
|
self,
|
|
scene: StandaloneScene,
|
|
*,
|
|
authorities: list[str] | None = None,
|
|
pinned_document_keys: list[str] | None = None,
|
|
model_hint: str | None = None,
|
|
) -> StandaloneScene:
|
|
if scene.status not in {SceneDraftStatus.PLANNING, SceneDraftStatus.PLAN_REVIEW}:
|
|
raise ValueError(f"scene cannot be planned from status {scene.status}")
|
|
self.prepare_context(
|
|
scene,
|
|
authorities=authorities,
|
|
pinned_document_keys=pinned_document_keys,
|
|
)
|
|
pack = scene.context_pack
|
|
prompt, prompt_version_id = render_authoring_prompt(
|
|
"STANDALONE_SCENE_PLAN",
|
|
STANDALONE_SCENE_PLAN_SYSTEM,
|
|
STANDALONE_SCENE_PLAN_TEMPLATE,
|
|
title=scene.title,
|
|
brief=scene.brief,
|
|
context=pack["rendered_context"],
|
|
constraints=json.dumps(scene.constraints, ensure_ascii=False, indent=2),
|
|
forbidden_events=json.dumps(scene.forbidden_events, ensure_ascii=False, indent=2),
|
|
boundary_constraints=json.dumps(
|
|
scene.boundary_constraints, ensure_ascii=False, indent=2
|
|
),
|
|
target_words=scene.target_words,
|
|
)
|
|
response = self.router.complete(
|
|
ModelRequestContract(
|
|
purpose=ModelCapability.STORY_PLANNING,
|
|
prompt=prompt,
|
|
model_hint=model_hint,
|
|
token_budget=5000,
|
|
project=scene.story.project if scene.story else None,
|
|
)
|
|
)
|
|
plan = self._normalize_plan(extract_json_object(response.content), scene)
|
|
discarded_exact_values = []
|
|
discarded_proposed_beats = []
|
|
if scene.book_state_id:
|
|
plan, discarded_proposed_beats = self._apply_book_chapter_scope(scene, plan)
|
|
plan["exact_values"], discarded_exact_values = self._scoped_exact_values(
|
|
scene, plan["exact_values"]
|
|
)
|
|
requirements = self._requirements(plan)
|
|
scene.plan = plan
|
|
scene.contract_requirements = requirements
|
|
scene.status = SceneDraftStatus.PLAN_REVIEW
|
|
scene.failure_reason = ""
|
|
metadata = dict(scene.generation_metadata or {})
|
|
metadata["planning"] = {
|
|
"model": response.model,
|
|
"prompt_version_id": prompt_version_id,
|
|
"prompt_sha256": text_sha256(prompt),
|
|
"response_sha256": text_sha256(response.content),
|
|
"context_pack_sha256": pack["sha256"],
|
|
"discarded_out_of_scope_exact_values": discarded_exact_values,
|
|
"discarded_model_proposed_beats": discarded_proposed_beats,
|
|
**self._book_state_metadata(scene),
|
|
}
|
|
scene.generation_metadata = metadata
|
|
scene.save()
|
|
return scene
|
|
|
|
def _apply_book_chapter_scope(
|
|
self, scene: StandaloneScene, plan: dict[str, Any]
|
|
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
chapter, _chapters, _index = self._book_chapter(
|
|
scene.book_state.content or {}, scene.book_chapter_key
|
|
)
|
|
approved_beats = [
|
|
{
|
|
"beat_id": str(item.get("beat_id") or "").strip(),
|
|
"text": str(item.get("text") or "").strip(),
|
|
"required": bool(item.get("required")),
|
|
"kind": "contract",
|
|
}
|
|
for item in chapter.get("beats") or []
|
|
if isinstance(item, dict) and str(item.get("text") or "").strip()
|
|
]
|
|
approved_by_text = {item["text"].casefold(): item for item in approved_beats}
|
|
approved_ids = {item["beat_id"] for item in approved_beats}
|
|
expansions_by_parent = {beat_id: [] for beat_id in approved_ids}
|
|
discarded = []
|
|
for item in plan["beats"]:
|
|
text = str(item.get("text") or "").strip()
|
|
if text.casefold() in approved_by_text:
|
|
continue
|
|
supports_beat_id = str(item.get("supports_beat_id") or "").strip()
|
|
kind = str(item.get("kind") or "").strip().lower()
|
|
if supports_beat_id not in approved_ids or kind not in {
|
|
"dramatization",
|
|
"transition",
|
|
}:
|
|
discarded.append(item)
|
|
continue
|
|
expansions_by_parent[supports_beat_id].append(
|
|
{
|
|
"text": text,
|
|
"required": False,
|
|
"supports_beat_id": supports_beat_id,
|
|
"kind": kind,
|
|
}
|
|
)
|
|
scoped_beats = []
|
|
for approved in approved_beats:
|
|
scoped_beats.append(approved)
|
|
scoped_beats.extend(expansions_by_parent[approved["beat_id"]])
|
|
plan["beats"] = scoped_beats
|
|
plan["purpose"] = str(chapter.get("purpose") or "").strip()
|
|
plan["ending_state"] = str(chapter.get("ending_state") or "").strip()
|
|
return plan, discarded
|
|
|
|
def _scoped_exact_values(
|
|
self, scene: StandaloneScene, exact_values: list[Any]
|
|
) -> tuple[list[Any], list[Any]]:
|
|
scope = "\n".join(
|
|
[
|
|
self._book_planning_packet(scene),
|
|
scene.brief,
|
|
json.dumps(scene.constraints, ensure_ascii=False),
|
|
json.dumps(scene.forbidden_events, ensure_ascii=False),
|
|
json.dumps(scene.boundary_constraints, ensure_ascii=False),
|
|
]
|
|
).casefold()
|
|
allowed = []
|
|
discarded = []
|
|
for item in exact_values:
|
|
value = item.get("value") if isinstance(item, dict) else item
|
|
candidate = "" if value is None else str(value).strip().casefold()
|
|
if candidate and candidate in scope:
|
|
allowed.append(item)
|
|
else:
|
|
discarded.append(item)
|
|
return allowed, discarded
|
|
|
|
def prepare_context(
|
|
self,
|
|
scene: StandaloneScene,
|
|
*,
|
|
authorities: list[str] | None = None,
|
|
pinned_document_keys: list[str] | None = None,
|
|
) -> StandaloneScene:
|
|
if scene.status not in {SceneDraftStatus.PLANNING, SceneDraftStatus.PLAN_REVIEW}:
|
|
raise ValueError(f"scene context cannot be changed from status {scene.status}")
|
|
query = "\n".join(
|
|
[scene.title, scene.brief, *scene.constraints, *scene.boundary_constraints]
|
|
)
|
|
pack, ranked = build_scene_context_pack(
|
|
work=scene.work,
|
|
query=query,
|
|
authorities=authorities,
|
|
pinned_document_keys=pinned_document_keys,
|
|
)
|
|
if scene.book_state_id:
|
|
packet = self._book_planning_packet(scene)
|
|
pack["rendered_context"] = f"{packet}\n\n{pack['rendered_context']}"
|
|
pack.update(self._book_state_metadata(scene))
|
|
pack["sha256"] = json_sha256(
|
|
{key: value for key, value in pack.items() if key != "sha256"}
|
|
)
|
|
with transaction.atomic():
|
|
scene.context_query = query
|
|
scene.context_pack = pack
|
|
scene.context_pack_sha256 = pack["sha256"]
|
|
scene.save(
|
|
update_fields=[
|
|
"context_query",
|
|
"context_pack",
|
|
"context_pack_sha256",
|
|
"updated_at",
|
|
]
|
|
)
|
|
scene.context_citations.all().delete()
|
|
SceneContextCitation.objects.bulk_create(
|
|
[
|
|
SceneContextCitation(
|
|
scene=scene,
|
|
passage=item.passage,
|
|
rank=index,
|
|
score=item.score,
|
|
reason=item.reason,
|
|
)
|
|
for index, item in enumerate(ranked, start=1)
|
|
]
|
|
)
|
|
return scene
|
|
|
|
def approve_plan(self, scene: StandaloneScene) -> StandaloneScene:
|
|
if scene.status != SceneDraftStatus.PLAN_REVIEW or not scene.plan:
|
|
raise ValueError("scene has no plan awaiting approval")
|
|
scene.status = SceneDraftStatus.READY
|
|
scene.plan_approved_at = timezone.now()
|
|
scene.save(update_fields=["status", "plan_approved_at", "updated_at"])
|
|
return scene
|
|
|
|
def write(
|
|
self,
|
|
scene: StandaloneScene,
|
|
*,
|
|
model_hint: str | None = None,
|
|
max_attempts: int = 2,
|
|
) -> StandaloneScene:
|
|
if scene.status not in {SceneDraftStatus.READY, SceneDraftStatus.FAILED}:
|
|
raise ValueError(f"scene cannot be written from status {scene.status}")
|
|
if not scene.plan_approved_at:
|
|
raise ValueError("scene plan must be approved before writing")
|
|
prompt, prompt_version_id = render_authoring_prompt(
|
|
"STANDALONE_SCENE_PROSE",
|
|
STANDALONE_SCENE_PROSE_SYSTEM,
|
|
STANDALONE_SCENE_PROSE_TEMPLATE,
|
|
title=scene.title,
|
|
brief=scene.brief,
|
|
context=(scene.context_pack or {}).get("rendered_context")
|
|
or "(No approved source context.)",
|
|
plan=json.dumps(scene.plan, ensure_ascii=False, indent=2),
|
|
requirements=json.dumps(scene.contract_requirements, ensure_ascii=False, indent=2),
|
|
target_words=scene.target_words,
|
|
)
|
|
partial_path, artifact_path, _review_path = self._artifact_paths(scene)
|
|
scene.status = SceneDraftStatus.DRAFTING
|
|
scene.partial_artifact_uri = str(partial_path)
|
|
scene.failure_reason = ""
|
|
scene.save(update_fields=["status", "partial_artifact_uri", "failure_reason", "updated_at"])
|
|
try:
|
|
result = self.writer.generate(
|
|
request=ModelRequestContract(
|
|
purpose=ModelCapability.STORY_PROSE,
|
|
prompt=prompt,
|
|
model_hint=model_hint,
|
|
token_budget=max(4000, int(scene.target_words * 2.2)),
|
|
project=scene.story.project if scene.story else None,
|
|
),
|
|
partial_path=partial_path,
|
|
minimum_words=max(250, int(scene.target_words * 0.6)),
|
|
maximum_words=max(600, int(scene.target_words * 1.8)),
|
|
completion_marker="[[END_OF_SCENE]]",
|
|
max_attempts=max(1, int(max_attempts)),
|
|
)
|
|
except Exception as exc:
|
|
scene.status = SceneDraftStatus.FAILED
|
|
scene.failure_reason = str(exc)
|
|
scene.save(update_fields=["status", "failure_reason", "updated_at"])
|
|
raise
|
|
prose = self._scene_document(scene.title, result.text)
|
|
atomic_write_text(artifact_path, prose + "\n")
|
|
scene.prose = prose
|
|
scene.artifact_uri = str(artifact_path)
|
|
scene.status = SceneDraftStatus.DRAFT_REVIEW
|
|
metadata = dict(scene.generation_metadata or {})
|
|
metadata["prose"] = {
|
|
"model_hint": model_hint,
|
|
"routed_model": model_hint or self.router.route(ModelCapability.STORY_PROSE),
|
|
"prompt_version_id": prompt_version_id,
|
|
"prompt_sha256": text_sha256(prompt),
|
|
"attempts": result.attempts,
|
|
"resumed": result.resumed,
|
|
"word_count": word_count(prose),
|
|
**self._book_state_metadata(scene),
|
|
}
|
|
scene.generation_metadata = metadata
|
|
scene.save()
|
|
return scene
|
|
|
|
def review(self, scene: StandaloneScene, *, model_hint: str | None = None) -> StandaloneScene:
|
|
if scene.status != SceneDraftStatus.DRAFT_REVIEW or not scene.prose:
|
|
raise ValueError("scene has no completed draft to review")
|
|
prose = self._scene_document(scene.title, scene.prose)
|
|
if prose != scene.prose:
|
|
_partial_path, artifact_path, _review_path = self._artifact_paths(scene)
|
|
atomic_write_text(artifact_path, prose + "\n")
|
|
scene.prose = prose
|
|
scene.review = {}
|
|
scene.save(
|
|
update_fields=[
|
|
"prose",
|
|
"word_count",
|
|
"sha256",
|
|
"review",
|
|
"updated_at",
|
|
]
|
|
)
|
|
prompt, prompt_version_id = render_authoring_prompt(
|
|
"STANDALONE_SCENE_REVIEW",
|
|
STANDALONE_SCENE_REVIEW_SYSTEM,
|
|
STANDALONE_SCENE_REVIEW_TEMPLATE,
|
|
context=(scene.context_pack or {}).get("rendered_context")
|
|
or "(No approved source context.)",
|
|
plan=json.dumps(scene.plan, ensure_ascii=False, indent=2),
|
|
requirements=json.dumps(scene.contract_requirements, ensure_ascii=False, indent=2),
|
|
prose=scene.prose,
|
|
)
|
|
response = self.router.complete(
|
|
ModelRequestContract(
|
|
purpose=ModelCapability.STORY_REVIEW,
|
|
prompt=prompt,
|
|
model_hint=model_hint,
|
|
token_budget=5000,
|
|
project=scene.story.project if scene.story else None,
|
|
)
|
|
)
|
|
review = self._normalize_review(
|
|
extract_json_object(response.content), scene.contract_requirements, scene.prose
|
|
)
|
|
_partial_path, _artifact_path, review_path = self._artifact_paths(scene)
|
|
atomic_write_text(review_path, json.dumps(review, ensure_ascii=False, indent=2) + "\n")
|
|
scene.review = review
|
|
scene.review_artifact_uri = str(review_path)
|
|
metadata = dict(scene.generation_metadata or {})
|
|
metadata["review"] = {
|
|
"model": response.model,
|
|
"prompt_version_id": prompt_version_id,
|
|
"prompt_sha256": text_sha256(prompt),
|
|
"response_sha256": text_sha256(response.content),
|
|
**self._book_state_metadata(scene),
|
|
}
|
|
scene.generation_metadata = metadata
|
|
scene.save(
|
|
update_fields=[
|
|
"review",
|
|
"review_artifact_uri",
|
|
"generation_metadata",
|
|
"updated_at",
|
|
]
|
|
)
|
|
return scene
|
|
|
|
@staticmethod
|
|
def _book_chapters(content: dict[str, Any]) -> list[dict[str, Any]]:
|
|
chapters: list[dict[str, Any]] = []
|
|
|
|
def append_chapters(value: Any) -> None:
|
|
if isinstance(value, dict):
|
|
for key, chapter in value.items():
|
|
if isinstance(chapter, dict):
|
|
chapters.append({"key": str(chapter.get("key") or key), **chapter})
|
|
elif isinstance(value, list):
|
|
chapters.extend(chapter for chapter in value if isinstance(chapter, dict))
|
|
|
|
append_chapters(content.get("chapters"))
|
|
acts = content.get("acts") or []
|
|
if isinstance(acts, dict):
|
|
acts = acts.values()
|
|
for act in acts:
|
|
if isinstance(act, dict):
|
|
append_chapters(act.get("chapters"))
|
|
return chapters
|
|
|
|
@classmethod
|
|
def _book_chapter(
|
|
cls, content: dict[str, Any], chapter_key: str
|
|
) -> tuple[dict[str, Any], list[dict[str, Any]], int]:
|
|
chapters = cls._book_chapters(content)
|
|
for index, chapter in enumerate(chapters):
|
|
key = str(
|
|
chapter.get("key")
|
|
or chapter.get("chapter_key")
|
|
or chapter.get("id")
|
|
or ""
|
|
)
|
|
if key == chapter_key:
|
|
return chapter, chapters, index
|
|
raise ValueError(f"book state chapter not found: {chapter_key}")
|
|
|
|
@classmethod
|
|
def _book_planning_packet(cls, scene: StandaloneScene) -> str:
|
|
state = scene.book_state
|
|
content = state.content or {}
|
|
chapter, chapters, index = cls._book_chapter(content, scene.book_chapter_key)
|
|
prior = chapters[index - 1] if index else {}
|
|
following = chapters[index + 1] if index + 1 < len(chapters) else {}
|
|
packet = {
|
|
"book_state": {
|
|
"id": str(state.id),
|
|
"version": state.version,
|
|
"sha256": state.sha256,
|
|
},
|
|
"book_constraints": content.get("constraints")
|
|
or content.get("book_constraints")
|
|
or content.get("global_constraints")
|
|
or [],
|
|
"book_forbidden_events": content.get("forbidden_events")
|
|
or content.get("book_forbidden_events")
|
|
or [],
|
|
"current_chapter": chapter,
|
|
"prior_chapter_ending": chapter.get("prior_ending")
|
|
or prior.get("ending_state")
|
|
or prior.get("ending")
|
|
or prior.get("final_state")
|
|
or "",
|
|
"next_chapter_purpose": chapter.get("next_purpose")
|
|
or following.get("purpose")
|
|
or "",
|
|
"relevant_arcs": cls._relevant_book_items(
|
|
content.get("arcs"),
|
|
chapter.get("arcs") or chapter.get("arc_keys") or chapter.get("arc_ids"),
|
|
scene.book_chapter_key,
|
|
),
|
|
"relevant_threads": cls._relevant_book_items(
|
|
content.get("threads") or content.get("plot_threads"),
|
|
chapter.get("threads")
|
|
or chapter.get("thread_keys")
|
|
or chapter.get("thread_ids"),
|
|
scene.book_chapter_key,
|
|
),
|
|
"continuity_facts": chapter.get("continuity_facts")
|
|
or cls._relevant_continuity(content, index),
|
|
}
|
|
return "PLANNING BOOK STATE PACKET (approved constraints)\n" + json.dumps(
|
|
packet, ensure_ascii=False, indent=2
|
|
)
|
|
|
|
@staticmethod
|
|
def _relevant_book_items(items: Any, references: Any, chapter_key: str) -> list[Any]:
|
|
if isinstance(references, dict):
|
|
references = [references]
|
|
elif isinstance(references, str):
|
|
references = [references]
|
|
references = references or []
|
|
if references and all(isinstance(item, dict) for item in references):
|
|
return list(references)
|
|
reference_keys = {str(value) for value in references}
|
|
if isinstance(items, dict):
|
|
values = [
|
|
{"key": key, **value}
|
|
for key, value in items.items()
|
|
if isinstance(value, dict)
|
|
]
|
|
elif isinstance(items, list):
|
|
values = items
|
|
else:
|
|
values = []
|
|
if not values:
|
|
return list(references)
|
|
if not reference_keys:
|
|
relevant = [
|
|
item
|
|
for item in values
|
|
if chapter_key
|
|
in {
|
|
str(value)
|
|
for value in (
|
|
item.get("chapters") or item.get("chapter_keys") or []
|
|
)
|
|
}
|
|
]
|
|
return relevant or values
|
|
return [
|
|
item
|
|
for item in values
|
|
if str(
|
|
item.get("key")
|
|
or item.get("id")
|
|
or item.get("arc_id")
|
|
or item.get("thread_id")
|
|
)
|
|
in reference_keys
|
|
]
|
|
|
|
@classmethod
|
|
def _relevant_continuity(
|
|
cls, content: dict[str, Any], chapter_index: int
|
|
) -> list[dict[str, Any]]:
|
|
chapter_positions = {
|
|
str(chapter.get("chapter_key") or chapter.get("key") or ""): index
|
|
for index, chapter in enumerate(cls._book_chapters(content))
|
|
}
|
|
relevant = []
|
|
for fact in content.get("continuity") or content.get("continuity_facts") or []:
|
|
if not isinstance(fact, dict):
|
|
continue
|
|
established = chapter_positions.get(str(fact.get("established_in") or ""))
|
|
resolved = chapter_positions.get(str(fact.get("resolved_in") or ""))
|
|
if established is None or established > chapter_index:
|
|
continue
|
|
if fact.get("resolved_in") and (resolved is None or resolved < chapter_index):
|
|
continue
|
|
relevant.append(fact)
|
|
return relevant
|
|
|
|
@staticmethod
|
|
def _book_state_metadata(scene: StandaloneScene) -> dict[str, Any]:
|
|
if not scene.book_state_id:
|
|
return {}
|
|
state = scene.book_state
|
|
return {
|
|
"book_state_id": str(state.id),
|
|
"book_state_version": state.version,
|
|
"book_state_sha256": state.sha256,
|
|
"book_chapter_key": scene.book_chapter_key,
|
|
}
|
|
|
|
@staticmethod
|
|
def _scene_document(title: str, prose: str) -> str:
|
|
body = prose.strip()
|
|
first_line = body.partition("\n")[0].lstrip("# ").strip()
|
|
if first_line == title.strip():
|
|
return body
|
|
return f"# {title.strip()}\n\n{body}"
|
|
|
|
def approve(
|
|
self,
|
|
scene: StandaloneScene,
|
|
*,
|
|
actor: str,
|
|
force: bool = False,
|
|
) -> StandaloneScene:
|
|
if scene.status != SceneDraftStatus.DRAFT_REVIEW or not scene.prose:
|
|
raise ValueError("scene has no completed draft awaiting approval")
|
|
if not scene.review:
|
|
raise ValueError("scene must be reviewed before approval")
|
|
if not scene.review.get("passed") and not force:
|
|
raise ValueError("scene review has blocking issues")
|
|
artifact_path = Path(scene.artifact_uri)
|
|
root = self._artifact_root(scene)
|
|
source = register_source(
|
|
work=scene.work,
|
|
path=artifact_path,
|
|
root=root,
|
|
authority=DocumentAuthority.PROVISIONAL,
|
|
document_type=DocumentType.SCENE,
|
|
)
|
|
source_document = scene.work.source_documents.get(logical_key=source.logical_key)
|
|
source_version = source_document.versions.get(version=source.version)
|
|
scene.status = SceneDraftStatus.APPROVED
|
|
scene.approved_at = timezone.now()
|
|
scene.approved_by = actor.strip()
|
|
scene.source_version = source_version
|
|
metadata = dict(scene.generation_metadata or {})
|
|
metadata["approval"] = {
|
|
"actor": scene.approved_by,
|
|
"forced": bool(force),
|
|
"source_version_id": str(source_version.id),
|
|
}
|
|
scene.generation_metadata = metadata
|
|
scene.save(
|
|
update_fields=[
|
|
"status",
|
|
"approved_at",
|
|
"approved_by",
|
|
"source_version",
|
|
"generation_metadata",
|
|
"updated_at",
|
|
]
|
|
)
|
|
return scene
|
|
|
|
def reject(self, scene: StandaloneScene, *, actor: str) -> StandaloneScene:
|
|
if scene.status == SceneDraftStatus.APPROVED:
|
|
raise ValueError(
|
|
"approved scenes cannot be rejected in place; create a superseding revision"
|
|
)
|
|
scene.status = SceneDraftStatus.REJECTED
|
|
scene.approved_by = actor.strip()
|
|
scene.save(update_fields=["status", "approved_by", "updated_at"])
|
|
return scene
|
|
|
|
def _normalize_plan(self, data: dict[str, Any], scene: StandaloneScene) -> dict[str, Any]:
|
|
beats = []
|
|
for raw in data.get("beats") or []:
|
|
if isinstance(raw, str):
|
|
raw = {"text": raw, "required": False}
|
|
text = str(raw.get("text") or "").strip()
|
|
if text:
|
|
beat = {"text": text, "required": bool(raw.get("required"))}
|
|
supports_beat_id = str(raw.get("supports_beat_id") or "").strip()
|
|
kind = str(raw.get("kind") or "").strip().lower()
|
|
if supports_beat_id:
|
|
beat["supports_beat_id"] = supports_beat_id
|
|
if kind:
|
|
beat["kind"] = kind
|
|
beats.append(beat)
|
|
if not beats:
|
|
raise ValueError("scene plan must contain at least one beat")
|
|
plan = {
|
|
"purpose": str(data.get("purpose") or "").strip(),
|
|
"pov_character": str(data.get("pov_character") or "").strip(),
|
|
"tense": str(data.get("tense") or "past").strip(),
|
|
"location": str(data.get("location") or "").strip(),
|
|
"time_context": str(data.get("time_context") or "").strip(),
|
|
"present": list(data.get("present") or []),
|
|
"target_words": scene.target_words,
|
|
"beats": beats[:12],
|
|
"exact_values": list(data.get("exact_values") or []),
|
|
"constraints": list(
|
|
dict.fromkeys([*scene.constraints, *(data.get("constraints") or [])])
|
|
),
|
|
"forbidden_events": list(
|
|
dict.fromkeys([*scene.forbidden_events, *(data.get("forbidden_events") or [])])
|
|
),
|
|
"ending_state": str(data.get("ending_state") or "").strip(),
|
|
"final_image": str(data.get("final_image") or "").strip(),
|
|
"boundary_constraints": list(
|
|
dict.fromkeys(
|
|
[*scene.boundary_constraints, *(data.get("boundary_constraints") or [])]
|
|
)
|
|
),
|
|
"continuity_questions": list(data.get("continuity_questions") or []),
|
|
}
|
|
if not plan["ending_state"]:
|
|
raise ValueError("scene plan must define ending_state")
|
|
return plan
|
|
|
|
def _requirements(self, plan: dict[str, Any]) -> list[dict[str, Any]]:
|
|
contract = {
|
|
"scenes": [
|
|
{
|
|
"number": 1,
|
|
"beats": plan["beats"],
|
|
"ending_state": plan["ending_state"],
|
|
}
|
|
],
|
|
"exact_values": plan["exact_values"],
|
|
"forbidden_events": plan["forbidden_events"],
|
|
"chapter_constraints": plan["constraints"],
|
|
"boundary_constraints": plan["boundary_constraints"],
|
|
"final_image": plan["final_image"],
|
|
}
|
|
return build_contract_requirements(contract, max_required_per_scene=None)
|
|
|
|
def _normalize_review(
|
|
self,
|
|
data: dict[str, Any],
|
|
requirements: list[dict[str, Any]],
|
|
prose: str,
|
|
) -> dict[str, Any]:
|
|
expected = {item["id"]: item for item in requirements}
|
|
results_by_id = {
|
|
str(item.get("requirement_id") or ""): item
|
|
for item in data.get("requirement_results") or []
|
|
if isinstance(item, dict)
|
|
}
|
|
requirement_results = []
|
|
blocking_failure = False
|
|
for requirement_id, requirement in expected.items():
|
|
raw = results_by_id.get(requirement_id) or {
|
|
"requirement_id": requirement_id,
|
|
"status": "UNVERIFIABLE",
|
|
"evidence_quote": "",
|
|
"details": "Reviewer omitted this frozen requirement.",
|
|
}
|
|
status = str(raw.get("status") or "UNVERIFIABLE").upper()
|
|
evidence = str(raw.get("evidence_quote") or "")
|
|
evidence_valid = bool(evidence and evidence_is_present(prose, evidence))
|
|
if requirement.get("blocking") and status != "HIT":
|
|
blocking_failure = True
|
|
requirement_results.append(
|
|
{
|
|
"requirement_id": requirement_id,
|
|
"status": status,
|
|
"evidence_quote": evidence,
|
|
"evidence_valid": evidence_valid,
|
|
"details": str(raw.get("details") or ""),
|
|
}
|
|
)
|
|
findings = []
|
|
for raw in data.get("findings") or []:
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
finding = dict(raw)
|
|
evidence = str(finding.get("evidence_quote") or "")
|
|
finding["evidence_valid"] = bool(evidence and evidence_is_present(prose, evidence))
|
|
severity = str(finding.get("severity") or "MEDIUM").upper()
|
|
finding["severity"] = severity
|
|
if severity in {"HIGH", "CRITICAL"}:
|
|
blocking_failure = True
|
|
findings.append(finding)
|
|
proposed_changes = []
|
|
for raw in data.get("proposed_changes") or []:
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
change = dict(raw)
|
|
evidence = str(change.get("evidence_quote") or "")
|
|
change["evidence_valid"] = bool(evidence and evidence_is_present(prose, evidence))
|
|
proposed_changes.append(change)
|
|
return {
|
|
"schema_version": 1,
|
|
"passed": bool(data.get("passed")) and not blocking_failure,
|
|
"requirement_results": requirement_results,
|
|
"findings": findings,
|
|
"observed_state": data.get("observed_state") or {},
|
|
"proposed_changes": proposed_changes,
|
|
}
|
|
|
|
def _artifact_root(self, scene: StandaloneScene) -> Path:
|
|
if scene.story and scene.story.artifact_root.strip():
|
|
return Path(scene.story.artifact_root)
|
|
return (
|
|
Path(settings.BASE_DIR)
|
|
/ "artifacts"
|
|
/ "stories"
|
|
/ scene.work.series.slug
|
|
/ scene.work.slug
|
|
)
|
|
|
|
def _artifact_paths(self, scene: StandaloneScene) -> tuple[Path, Path, Path]:
|
|
directory = self._artifact_root(scene) / "scenes"
|
|
if scene.book_state_id:
|
|
directory = (
|
|
directory
|
|
/ f"book-state-v{scene.book_state.version:04d}-{scene.book_state_id}"
|
|
/ scene.book_chapter_key
|
|
)
|
|
directory /= scene.scene_key
|
|
stem = f"r{scene.revision:02d}"
|
|
return (
|
|
directory / f"{stem}.partial.md",
|
|
directory / f"{stem}.md",
|
|
directory / f"{stem}.review.json",
|
|
)
|