959 lines
33 KiB
Python
959 lines
33 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
|
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
from django.db import models
|
|
from django.db.models import Q
|
|
|
|
from control_plane.common import TimestampedModel
|
|
|
|
|
|
def text_sha256(value: str) -> str:
|
|
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
|
|
|
|
class StoryStatus(models.TextChoices):
|
|
PLANNING = "PLANNING"
|
|
REVISING = "REVISING"
|
|
DRAFTING = "DRAFTING"
|
|
COMPLETE = "COMPLETE"
|
|
|
|
|
|
class ChapterStatus(models.TextChoices):
|
|
PLANNED = "PLANNED"
|
|
DRAFTING = "DRAFTING"
|
|
REVIEW = "REVIEW"
|
|
APPROVED = "APPROVED"
|
|
|
|
|
|
class RevisionStatus(models.TextChoices):
|
|
SOURCE = "SOURCE"
|
|
DRAFT = "DRAFT"
|
|
REVIEW = "REVIEW"
|
|
APPROVED = "APPROVED"
|
|
REJECTED = "REJECTED"
|
|
|
|
|
|
class FindingSeverity(models.TextChoices):
|
|
INFO = "INFO"
|
|
LOW = "LOW"
|
|
MEDIUM = "MEDIUM"
|
|
HIGH = "HIGH"
|
|
CRITICAL = "CRITICAL"
|
|
|
|
|
|
class FindingStatus(models.TextChoices):
|
|
OPEN = "OPEN"
|
|
RESOLVED = "RESOLVED"
|
|
ACCEPTED = "ACCEPTED"
|
|
|
|
|
|
class StateDocumentStatus(models.TextChoices):
|
|
EXTRACTED = "EXTRACTED"
|
|
NEEDS_REVISION = "NEEDS_REVISION"
|
|
VALIDATED = "VALIDATED"
|
|
COMMITTED = "COMMITTED"
|
|
|
|
|
|
class RequirementStatus(models.TextChoices):
|
|
HIT = "HIT"
|
|
PARTIAL = "PARTIAL"
|
|
MISSED = "MISSED"
|
|
CONTRADICTED = "CONTRADICTED"
|
|
UNVERIFIABLE = "UNVERIFIABLE"
|
|
|
|
|
|
class StateChangeStatus(models.TextChoices):
|
|
PROPOSED = "PROPOSED"
|
|
VALIDATED = "VALIDATED"
|
|
COMMITTED = "COMMITTED"
|
|
REJECTED = "REJECTED"
|
|
|
|
|
|
class StateOperation(models.TextChoices):
|
|
SET = "SET"
|
|
ADD = "ADD"
|
|
REMOVE = "REMOVE"
|
|
TRANSFER = "TRANSFER"
|
|
OPEN = "OPEN"
|
|
CLOSE = "CLOSE"
|
|
|
|
|
|
class WorkType(models.TextChoices):
|
|
BOOK = "book"
|
|
SERIES_REFERENCE = "series_reference"
|
|
OTHER = "other"
|
|
|
|
|
|
class DocumentType(models.TextChoices):
|
|
MANUSCRIPT = "manuscript"
|
|
SCENE = "scene"
|
|
OUTLINE = "outline"
|
|
PLANNING = "planning"
|
|
CANON = "canon"
|
|
STATE = "state"
|
|
REFERENCE = "reference"
|
|
OTHER = "other"
|
|
|
|
|
|
class DocumentAuthority(models.TextChoices):
|
|
CANON = "canon"
|
|
PROVISIONAL = "provisional"
|
|
PLANNING = "planning"
|
|
SUPERSEDED = "superseded"
|
|
REJECTED = "rejected"
|
|
NONCANON_EXPERIMENT = "noncanon_experiment"
|
|
|
|
|
|
class SceneDraftStatus(models.TextChoices):
|
|
PLANNING = "planning"
|
|
PLAN_REVIEW = "plan_review"
|
|
READY = "ready"
|
|
DRAFTING = "drafting"
|
|
DRAFT_REVIEW = "draft_review"
|
|
APPROVED = "approved"
|
|
REJECTED = "rejected"
|
|
FAILED = "failed"
|
|
|
|
|
|
class BookStateStatus(models.TextChoices):
|
|
DRAFT = "draft"
|
|
REVIEW = "review"
|
|
APPROVED = "approved"
|
|
REJECTED = "rejected"
|
|
|
|
|
|
class BookRunStatus(models.TextChoices):
|
|
PENDING = "pending"
|
|
RUNNING = "running"
|
|
PAUSED = "paused"
|
|
REVIEW = "review"
|
|
COMPLETE = "complete"
|
|
FAILED = "failed"
|
|
CANCELLED = "cancelled"
|
|
|
|
|
|
class Series(TimestampedModel):
|
|
title = models.CharField(max_length=255)
|
|
slug = models.SlugField(max_length=160, unique=True)
|
|
description = models.TextField(blank=True)
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
verbose_name_plural = "series"
|
|
|
|
def __str__(self) -> str:
|
|
return self.title
|
|
|
|
|
|
class Work(TimestampedModel):
|
|
series = models.ForeignKey(Series, on_delete=models.CASCADE, related_name="works")
|
|
title = models.CharField(max_length=255)
|
|
slug = models.SlugField(max_length=160)
|
|
work_type = models.CharField(
|
|
max_length=32, choices=WorkType.choices, default=WorkType.BOOK
|
|
)
|
|
sequence = models.PositiveIntegerField(null=True, blank=True)
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
current_book_state = models.ForeignKey(
|
|
"BookStateVersion",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="current_for_works",
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["sequence", "title"]
|
|
constraints = [
|
|
models.UniqueConstraint(fields=["series", "slug"], name="unique_series_work_slug")
|
|
]
|
|
|
|
def __str__(self) -> str:
|
|
return self.title
|
|
|
|
|
|
class StoryProject(TimestampedModel):
|
|
project = models.OneToOneField(
|
|
"projects.Project", on_delete=models.CASCADE, related_name="story_project"
|
|
)
|
|
work = models.OneToOneField(
|
|
Work,
|
|
on_delete=models.PROTECT,
|
|
related_name="story_project",
|
|
null=True,
|
|
blank=True,
|
|
)
|
|
title = models.CharField(max_length=255)
|
|
series = models.CharField(max_length=255, blank=True)
|
|
slug = models.SlugField(max_length=160, unique=True)
|
|
status = models.CharField(
|
|
max_length=32, choices=StoryStatus.choices, default=StoryStatus.PLANNING
|
|
)
|
|
artifact_root = models.TextField(blank=True)
|
|
config = models.JSONField(default=dict, blank=True)
|
|
|
|
def __str__(self) -> str:
|
|
return self.title
|
|
|
|
|
|
class SourceDocument(TimestampedModel):
|
|
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="source_documents")
|
|
logical_key = models.CharField(max_length=500)
|
|
title = models.CharField(max_length=500)
|
|
document_type = models.CharField(
|
|
max_length=32, choices=DocumentType.choices, default=DocumentType.OTHER
|
|
)
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["logical_key"]
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["work", "logical_key"], name="unique_work_source_document_key"
|
|
)
|
|
]
|
|
|
|
def __str__(self) -> str:
|
|
return self.title
|
|
|
|
|
|
class SourceDocumentVersion(TimestampedModel):
|
|
document = models.ForeignKey(SourceDocument, on_delete=models.CASCADE, related_name="versions")
|
|
version = models.PositiveIntegerField()
|
|
authority = models.CharField(
|
|
max_length=32,
|
|
choices=DocumentAuthority.choices,
|
|
default=DocumentAuthority.PROVISIONAL,
|
|
db_index=True,
|
|
)
|
|
source_path = models.TextField()
|
|
content = models.TextField()
|
|
source_sha256 = models.CharField(max_length=64, db_index=True)
|
|
byte_size = models.PositiveBigIntegerField()
|
|
encoding = models.CharField(max_length=40, default="utf-8")
|
|
supersedes = models.ForeignKey(
|
|
"self",
|
|
on_delete=models.PROTECT,
|
|
null=True,
|
|
blank=True,
|
|
related_name="superseded_by",
|
|
)
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["document", "version"]
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["document", "version"], name="unique_source_document_version"
|
|
),
|
|
]
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.document.title} v{self.version}"
|
|
|
|
def save(self, *args: object, **kwargs: object) -> None:
|
|
if not self._state.adding:
|
|
original = SourceDocumentVersion.objects.get(pk=self.pk)
|
|
immutable_fields = (
|
|
"document_id",
|
|
"version",
|
|
"authority",
|
|
"source_path",
|
|
"content",
|
|
"source_sha256",
|
|
"byte_size",
|
|
"encoding",
|
|
"supersedes_id",
|
|
"metadata",
|
|
)
|
|
changed = any(
|
|
getattr(self, field) != getattr(original, field) for field in immutable_fields
|
|
)
|
|
if changed:
|
|
raise ValueError(
|
|
"source document versions are immutable; create a superseding version"
|
|
)
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class SourcePassage(TimestampedModel):
|
|
document_version = models.ForeignKey(
|
|
SourceDocumentVersion, on_delete=models.CASCADE, related_name="passages"
|
|
)
|
|
ordinal = models.PositiveIntegerField()
|
|
start_line = models.PositiveIntegerField()
|
|
end_line = models.PositiveIntegerField()
|
|
start_char = models.PositiveBigIntegerField()
|
|
end_char = models.PositiveBigIntegerField()
|
|
content = models.TextField()
|
|
sha256 = models.CharField(max_length=64, db_index=True)
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["document_version", "ordinal"]
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["document_version", "ordinal"],
|
|
name="unique_source_document_passage_ordinal",
|
|
),
|
|
models.CheckConstraint(
|
|
condition=Q(end_line__gte=models.F("start_line")),
|
|
name="source_passage_line_range_valid",
|
|
),
|
|
models.CheckConstraint(
|
|
condition=Q(end_char__gte=models.F("start_char")),
|
|
name="source_passage_char_range_valid",
|
|
),
|
|
]
|
|
|
|
def save(self, *args: object, **kwargs: object) -> None:
|
|
if not self._state.adding:
|
|
original = SourcePassage.objects.get(pk=self.pk)
|
|
immutable_fields = (
|
|
"document_version_id",
|
|
"ordinal",
|
|
"start_line",
|
|
"end_line",
|
|
"start_char",
|
|
"end_char",
|
|
"content",
|
|
"sha256",
|
|
"metadata",
|
|
)
|
|
changed = any(
|
|
getattr(self, field) != getattr(original, field) for field in immutable_fields
|
|
)
|
|
if changed:
|
|
raise ValueError("source passages are immutable with their document version")
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class BookStateVersion(TimestampedModel):
|
|
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="book_state_versions")
|
|
parent = models.ForeignKey(
|
|
"self",
|
|
on_delete=models.PROTECT,
|
|
null=True,
|
|
blank=True,
|
|
related_name="children",
|
|
)
|
|
version = models.PositiveIntegerField()
|
|
status = models.CharField(
|
|
max_length=16, choices=BookStateStatus.choices, default=BookStateStatus.DRAFT
|
|
)
|
|
content = models.JSONField()
|
|
sha256 = models.CharField(max_length=64, db_index=True)
|
|
context_pack = models.JSONField(default=dict, blank=True)
|
|
context_pack_sha256 = models.CharField(max_length=64, blank=True)
|
|
validation = models.JSONField(default=dict, blank=True)
|
|
reviews = models.JSONField(default=dict, blank=True)
|
|
change_summary = models.JSONField(default=dict, blank=True)
|
|
generation_metadata = models.JSONField(default=dict, blank=True)
|
|
created_by = models.CharField(max_length=160, blank=True)
|
|
json_artifact_uri = models.TextField(blank=True)
|
|
markdown_artifact_uri = models.TextField(blank=True)
|
|
approved_at = models.DateTimeField(null=True, blank=True)
|
|
approved_by = models.CharField(max_length=160, blank=True)
|
|
approval_notes = models.TextField(blank=True)
|
|
approval_forced = models.BooleanField(default=False)
|
|
rejected_at = models.DateTimeField(null=True, blank=True)
|
|
rejected_by = models.CharField(max_length=160, blank=True)
|
|
rejection_notes = models.TextField(blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["work", "version"]
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["work", "version"], name="unique_work_book_state_version"
|
|
)
|
|
]
|
|
|
|
def save(self, *args: object, **kwargs: object) -> None:
|
|
if self._state.adding:
|
|
canonical = json.dumps(
|
|
self.content, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
)
|
|
self.sha256 = text_sha256(canonical)
|
|
if self.context_pack:
|
|
canonical_context = json.dumps(
|
|
self.context_pack,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
)
|
|
self.context_pack_sha256 = text_sha256(canonical_context)
|
|
else:
|
|
original = BookStateVersion.objects.get(pk=self.pk)
|
|
immutable_fields = (
|
|
"work_id",
|
|
"parent_id",
|
|
"version",
|
|
"content",
|
|
"sha256",
|
|
"context_pack",
|
|
"context_pack_sha256",
|
|
"generation_metadata",
|
|
"created_by",
|
|
)
|
|
if original.status == BookStateStatus.APPROVED:
|
|
immutable_fields += (
|
|
"status",
|
|
"reviews",
|
|
"validation",
|
|
"change_summary",
|
|
"approved_at",
|
|
"approved_by",
|
|
"approval_notes",
|
|
"approval_forced",
|
|
"rejected_at",
|
|
"rejected_by",
|
|
"rejection_notes",
|
|
"json_artifact_uri",
|
|
"markdown_artifact_uri",
|
|
)
|
|
if any(
|
|
getattr(self, field) != getattr(original, field) for field in immutable_fields
|
|
):
|
|
raise ValueError(
|
|
"book state versions are immutable; create a child version"
|
|
)
|
|
super().save(*args, **kwargs)
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.work} v{self.version}"
|
|
|
|
|
|
class StandaloneScene(TimestampedModel):
|
|
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="standalone_scenes")
|
|
story = models.ForeignKey(
|
|
StoryProject,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="standalone_scenes",
|
|
)
|
|
parent = models.ForeignKey(
|
|
"self",
|
|
on_delete=models.PROTECT,
|
|
null=True,
|
|
blank=True,
|
|
related_name="revisions",
|
|
)
|
|
source_version = models.ForeignKey(
|
|
SourceDocumentVersion,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="generated_scenes",
|
|
)
|
|
book_state = models.ForeignKey(
|
|
BookStateVersion,
|
|
on_delete=models.PROTECT,
|
|
null=True,
|
|
blank=True,
|
|
related_name="standalone_scenes",
|
|
)
|
|
book_chapter_key = models.CharField(max_length=80, blank=True)
|
|
scene_key = models.SlugField(max_length=200)
|
|
revision = models.PositiveIntegerField(default=1)
|
|
title = models.CharField(max_length=500)
|
|
status = models.CharField(
|
|
max_length=32, choices=SceneDraftStatus.choices, default=SceneDraftStatus.PLANNING
|
|
)
|
|
brief = models.TextField()
|
|
target_words = models.PositiveIntegerField(
|
|
default=1800,
|
|
validators=[MinValueValidator(300), MaxValueValidator(10000)],
|
|
)
|
|
constraints = models.JSONField(default=list, blank=True)
|
|
forbidden_events = models.JSONField(default=list, blank=True)
|
|
boundary_constraints = models.JSONField(default=list, blank=True)
|
|
context_query = models.TextField(blank=True)
|
|
context_pack = models.JSONField(default=dict, blank=True)
|
|
context_pack_sha256 = models.CharField(max_length=64, blank=True)
|
|
plan = models.JSONField(default=dict, blank=True)
|
|
contract_requirements = models.JSONField(default=list, blank=True)
|
|
prose = models.TextField(blank=True)
|
|
word_count = models.PositiveIntegerField(default=0)
|
|
sha256 = models.CharField(max_length=64, blank=True)
|
|
partial_artifact_uri = models.TextField(blank=True)
|
|
artifact_uri = models.TextField(blank=True)
|
|
review_artifact_uri = models.TextField(blank=True)
|
|
review = models.JSONField(default=dict, blank=True)
|
|
generation_metadata = models.JSONField(default=dict, blank=True)
|
|
plan_approved_at = models.DateTimeField(null=True, blank=True)
|
|
approved_at = models.DateTimeField(null=True, blank=True)
|
|
approved_by = models.CharField(max_length=160, blank=True)
|
|
failure_reason = models.TextField(blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-updated_at"]
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["work", "scene_key", "revision"],
|
|
condition=Q(book_state__isnull=True),
|
|
name="unique_unbound_scene_revision",
|
|
),
|
|
models.UniqueConstraint(
|
|
fields=[
|
|
"work",
|
|
"book_state",
|
|
"book_chapter_key",
|
|
"scene_key",
|
|
"revision",
|
|
],
|
|
condition=Q(book_state__isnull=False),
|
|
name="unique_bound_scene_revision",
|
|
),
|
|
models.CheckConstraint(
|
|
condition=(Q(book_state__isnull=True) & Q(book_chapter_key=""))
|
|
| (Q(book_state__isnull=False) & ~Q(book_chapter_key="")),
|
|
name="scene_book_state_chapter_key_paired",
|
|
),
|
|
]
|
|
|
|
def save(self, *args: object, **kwargs: object) -> None:
|
|
if not self._state.adding:
|
|
original = StandaloneScene.objects.get(pk=self.pk)
|
|
if original.status == SceneDraftStatus.APPROVED:
|
|
immutable_fields = (
|
|
"work_id",
|
|
"story_id",
|
|
"parent_id",
|
|
"source_version_id",
|
|
"book_state_id",
|
|
"book_chapter_key",
|
|
"scene_key",
|
|
"revision",
|
|
"title",
|
|
"status",
|
|
"brief",
|
|
"target_words",
|
|
"constraints",
|
|
"forbidden_events",
|
|
"boundary_constraints",
|
|
"context_query",
|
|
"context_pack",
|
|
"context_pack_sha256",
|
|
"plan",
|
|
"contract_requirements",
|
|
"prose",
|
|
"sha256",
|
|
"artifact_uri",
|
|
"review",
|
|
"review_artifact_uri",
|
|
"generation_metadata",
|
|
"approved_at",
|
|
"approved_by",
|
|
)
|
|
changed = any(
|
|
getattr(self, field) != getattr(original, field)
|
|
for field in immutable_fields
|
|
)
|
|
if changed:
|
|
raise ValueError(
|
|
"approved standalone scenes are immutable; create a new revision"
|
|
)
|
|
if self.prose:
|
|
import re
|
|
|
|
self.word_count = len(re.findall(r"\b\S+\b", self.prose))
|
|
self.sha256 = text_sha256(self.prose)
|
|
super().save(*args, **kwargs)
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.title} r{self.revision}"
|
|
|
|
|
|
class SceneIdeation(TimestampedModel):
|
|
work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="scene_ideations")
|
|
book_state = models.ForeignKey(
|
|
BookStateVersion,
|
|
on_delete=models.PROTECT,
|
|
null=True,
|
|
blank=True,
|
|
related_name="scene_ideations",
|
|
)
|
|
target_book = models.CharField(max_length=160, blank=True)
|
|
focus = models.TextField(blank=True)
|
|
requested_scene_types = models.JSONField(default=list, blank=True)
|
|
candidate_count = models.PositiveSmallIntegerField(
|
|
default=10,
|
|
validators=[MinValueValidator(1), MaxValueValidator(12)],
|
|
)
|
|
authorities = models.JSONField(default=list)
|
|
pinned_document_keys = models.JSONField(default=list, blank=True)
|
|
context_pack = models.JSONField(default=dict)
|
|
context_pack_sha256 = models.CharField(max_length=64)
|
|
candidates = models.JSONField(default=list)
|
|
generation_metadata = models.JSONField(default=dict)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.work}: {self.candidate_count} scene ideas"
|
|
|
|
|
|
class BookRun(TimestampedModel):
|
|
"""Durable orchestration cursor for a book state, not prose state."""
|
|
|
|
book_state = models.ForeignKey(
|
|
BookStateVersion, on_delete=models.PROTECT, related_name="runs"
|
|
)
|
|
status = models.CharField(
|
|
max_length=16,
|
|
choices=BookRunStatus.choices,
|
|
default=BookRunStatus.PENDING,
|
|
db_index=True,
|
|
)
|
|
current_chapter_key = models.CharField(max_length=80, blank=True)
|
|
progress = models.JSONField(default=dict)
|
|
policy = models.JSONField(default=dict)
|
|
reviews = models.JSONField(default=dict, blank=True)
|
|
failure_reason = models.TextField(blank=True)
|
|
started_at = models.DateTimeField(null=True, blank=True)
|
|
finished_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
|
|
|
|
class SceneContextCitation(TimestampedModel):
|
|
scene = models.ForeignKey(
|
|
StandaloneScene, on_delete=models.CASCADE, related_name="context_citations"
|
|
)
|
|
passage = models.ForeignKey(
|
|
SourcePassage, on_delete=models.PROTECT, related_name="scene_citations"
|
|
)
|
|
rank = models.PositiveIntegerField()
|
|
score = models.FloatField(default=0)
|
|
reason = models.CharField(max_length=255, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["scene", "rank"]
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["scene", "passage"], name="unique_scene_context_passage"
|
|
),
|
|
models.UniqueConstraint(
|
|
fields=["scene", "rank"], name="unique_scene_context_rank"
|
|
),
|
|
]
|
|
|
|
|
|
class StoryBibleVersion(TimestampedModel):
|
|
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="bible_versions")
|
|
version = models.PositiveIntegerField()
|
|
content = models.TextField()
|
|
structured_canon = models.JSONField(default=dict, blank=True)
|
|
sha256 = models.CharField(max_length=64, blank=True)
|
|
approved_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(fields=["story", "version"], name="unique_story_bible_version")
|
|
]
|
|
|
|
def save(self, *args: object, **kwargs: object) -> None:
|
|
self.sha256 = text_sha256(self.content)
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class OutlineVersion(TimestampedModel):
|
|
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="outline_versions")
|
|
version = models.PositiveIntegerField()
|
|
content = models.JSONField(default=dict)
|
|
sha256 = models.CharField(max_length=64, blank=True)
|
|
approved_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(fields=["story", "version"], name="unique_story_outline_version")
|
|
]
|
|
|
|
def save(self, *args: object, **kwargs: object) -> None:
|
|
import json
|
|
|
|
canonical = json.dumps(self.content, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
self.sha256 = text_sha256(canonical)
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class PromptVersion(TimestampedModel):
|
|
name = models.CharField(max_length=160)
|
|
purpose = models.CharField(max_length=80)
|
|
version = models.PositiveIntegerField()
|
|
system_text = models.TextField(blank=True)
|
|
user_template = models.TextField()
|
|
config = models.JSONField(default=dict, blank=True)
|
|
is_active = models.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(fields=["name", "version"], name="unique_authoring_prompt_version"),
|
|
models.UniqueConstraint(
|
|
fields=["purpose"],
|
|
condition=Q(is_active=True),
|
|
name="unique_active_authoring_prompt_purpose",
|
|
),
|
|
]
|
|
|
|
|
|
class Chapter(TimestampedModel):
|
|
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="chapters")
|
|
number = models.PositiveIntegerField()
|
|
title = models.CharField(max_length=255)
|
|
status = models.CharField(
|
|
max_length=32, choices=ChapterStatus.choices, default=ChapterStatus.PLANNED
|
|
)
|
|
current_revision = models.ForeignKey(
|
|
"ChapterRevision",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="current_for_chapters",
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["number"]
|
|
constraints = [
|
|
models.UniqueConstraint(fields=["story", "number"], name="unique_story_chapter_number")
|
|
]
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.story.title} - Chapter {self.number}: {self.title}"
|
|
|
|
|
|
class GenerationContextSnapshot(TimestampedModel):
|
|
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="context_snapshots")
|
|
chapter = models.ForeignKey(Chapter, on_delete=models.CASCADE, related_name="context_snapshots")
|
|
story_bible = models.ForeignKey(StoryBibleVersion, on_delete=models.PROTECT)
|
|
outline = models.ForeignKey(OutlineVersion, on_delete=models.PROTECT)
|
|
prior_canon = models.ForeignKey(
|
|
"CanonSnapshot", on_delete=models.PROTECT, null=True, blank=True, related_name="derived_contexts"
|
|
)
|
|
content = models.JSONField(default=dict)
|
|
sha256 = models.CharField(max_length=64)
|
|
|
|
|
|
class ChapterRevision(TimestampedModel):
|
|
chapter = models.ForeignKey(Chapter, on_delete=models.CASCADE, related_name="revisions")
|
|
revision = models.PositiveIntegerField()
|
|
status = models.CharField(
|
|
max_length=32, choices=RevisionStatus.choices, default=RevisionStatus.DRAFT
|
|
)
|
|
parent = models.ForeignKey(
|
|
"self", on_delete=models.SET_NULL, null=True, blank=True, related_name="children"
|
|
)
|
|
source_revision = models.ForeignKey(
|
|
"self", on_delete=models.SET_NULL, null=True, blank=True, related_name="source_children"
|
|
)
|
|
story_bible = models.ForeignKey(StoryBibleVersion, on_delete=models.PROTECT)
|
|
outline = models.ForeignKey(OutlineVersion, on_delete=models.PROTECT)
|
|
context_snapshot = models.ForeignKey(
|
|
GenerationContextSnapshot,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="revisions",
|
|
)
|
|
scene_plan = models.JSONField(default=dict, blank=True)
|
|
prose = models.TextField(blank=True)
|
|
continuity_state = models.JSONField(default=dict, blank=True)
|
|
artifact_uri = models.TextField(blank=True)
|
|
word_count = models.PositiveIntegerField(default=0)
|
|
sha256 = models.CharField(max_length=64, blank=True)
|
|
graph_thread_id = models.CharField(max_length=255, blank=True, db_index=True)
|
|
generation_metadata = models.JSONField(default=dict, blank=True)
|
|
approved_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["chapter", "revision"], name="unique_chapter_revision_number"
|
|
)
|
|
]
|
|
|
|
def save(self, *args: object, **kwargs: object) -> None:
|
|
if self.prose:
|
|
import re
|
|
|
|
self.word_count = len(re.findall(r"\b\S+\b", self.prose))
|
|
self.sha256 = text_sha256(self.prose)
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class ChapterContract(TimestampedModel):
|
|
revision = models.OneToOneField(
|
|
ChapterRevision, on_delete=models.CASCADE, related_name="contract"
|
|
)
|
|
entry_canon = models.ForeignKey(
|
|
"CanonSnapshot",
|
|
on_delete=models.PROTECT,
|
|
null=True,
|
|
blank=True,
|
|
related_name="chapter_contracts",
|
|
)
|
|
requirements = models.JSONField(default=list)
|
|
scene_plan_sha256 = models.CharField(max_length=64)
|
|
approved_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
|
|
class ChapterStateDocument(TimestampedModel):
|
|
revision = models.OneToOneField(
|
|
ChapterRevision, on_delete=models.CASCADE, related_name="state_document"
|
|
)
|
|
contract = models.ForeignKey(
|
|
ChapterContract, on_delete=models.PROTECT, related_name="state_documents"
|
|
)
|
|
status = models.CharField(
|
|
max_length=32,
|
|
choices=StateDocumentStatus.choices,
|
|
default=StateDocumentStatus.EXTRACTED,
|
|
)
|
|
start_state = models.JSONField(default=dict)
|
|
observed_state = models.JSONField(default=dict)
|
|
proposed_delta = models.JSONField(default=list)
|
|
coverage = models.JSONField(default=dict)
|
|
verdict = models.CharField(max_length=32, blank=True)
|
|
json_artifact_uri = models.TextField(blank=True)
|
|
markdown_artifact_uri = models.TextField(blank=True)
|
|
sha256 = models.CharField(max_length=64)
|
|
model_metadata = models.JSONField(default=dict, blank=True)
|
|
validated_at = models.DateTimeField(null=True, blank=True)
|
|
committed_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
|
|
class StoryEntity(TimestampedModel):
|
|
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="state_entities")
|
|
entity_key = models.CharField(max_length=200)
|
|
kind = models.CharField(max_length=64)
|
|
canonical_name = models.CharField(max_length=255)
|
|
aliases = models.JSONField(default=list, blank=True)
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
first_revision = models.ForeignKey(
|
|
ChapterRevision,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="introduced_state_entities",
|
|
)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["story", "entity_key"], name="unique_story_state_entity_key"
|
|
)
|
|
]
|
|
|
|
|
|
class StateChange(TimestampedModel):
|
|
state_document = models.ForeignKey(
|
|
ChapterStateDocument, on_delete=models.CASCADE, related_name="changes"
|
|
)
|
|
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="state_changes")
|
|
revision = models.ForeignKey(
|
|
ChapterRevision, on_delete=models.CASCADE, related_name="state_changes"
|
|
)
|
|
entity = models.ForeignKey(
|
|
StoryEntity, on_delete=models.PROTECT, null=True, blank=True, related_name="changes"
|
|
)
|
|
related_entity = models.ForeignKey(
|
|
StoryEntity,
|
|
on_delete=models.PROTECT,
|
|
null=True,
|
|
blank=True,
|
|
related_name="related_changes",
|
|
)
|
|
sequence = models.PositiveIntegerField()
|
|
change_type = models.CharField(max_length=80)
|
|
predicate = models.CharField(max_length=200)
|
|
operation = models.CharField(max_length=16, choices=StateOperation.choices)
|
|
previous_value = models.JSONField(null=True, blank=True)
|
|
new_value = models.JSONField(null=True, blank=True)
|
|
effective_chapter = models.PositiveIntegerField()
|
|
evidence_quote = models.TextField(blank=True)
|
|
evidence_location = models.CharField(max_length=255, blank=True)
|
|
status = models.CharField(
|
|
max_length=16,
|
|
choices=StateChangeStatus.choices,
|
|
default=StateChangeStatus.PROPOSED,
|
|
)
|
|
supersedes = models.ForeignKey(
|
|
"self",
|
|
on_delete=models.PROTECT,
|
|
null=True,
|
|
blank=True,
|
|
related_name="superseded_by",
|
|
)
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
sha256 = models.CharField(max_length=64)
|
|
|
|
class Meta:
|
|
ordering = ["effective_chapter", "sequence"]
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["state_document", "sequence"], name="unique_state_change_sequence"
|
|
)
|
|
]
|
|
|
|
|
|
class RequirementCheck(TimestampedModel):
|
|
state_document = models.ForeignKey(
|
|
ChapterStateDocument, on_delete=models.CASCADE, related_name="requirement_checks"
|
|
)
|
|
requirement_id = models.CharField(max_length=80)
|
|
requirement_type = models.CharField(max_length=32)
|
|
requirement_text = models.TextField()
|
|
status = models.CharField(max_length=24, choices=RequirementStatus.choices)
|
|
severity = models.CharField(max_length=16, choices=FindingSeverity.choices)
|
|
evidence_quote = models.TextField(blank=True)
|
|
evidence_location = models.CharField(max_length=255, blank=True)
|
|
details = models.TextField(blank=True)
|
|
model_metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["state_document", "requirement_id"],
|
|
name="unique_state_document_requirement",
|
|
)
|
|
]
|
|
|
|
|
|
class CanonSnapshot(TimestampedModel):
|
|
story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="canon_snapshots")
|
|
through_chapter = models.PositiveIntegerField()
|
|
version = models.PositiveIntegerField()
|
|
state = models.JSONField(default=dict)
|
|
source_revision = models.OneToOneField(
|
|
ChapterRevision, on_delete=models.PROTECT, related_name="committed_canon"
|
|
)
|
|
sha256 = models.CharField(max_length=64)
|
|
|
|
class Meta:
|
|
ordering = ["version"]
|
|
constraints = [
|
|
models.UniqueConstraint(fields=["story", "version"], name="unique_story_canon_version")
|
|
]
|
|
|
|
|
|
class EditorialFinding(TimestampedModel):
|
|
revision = models.ForeignKey(ChapterRevision, on_delete=models.CASCADE, related_name="findings")
|
|
review_kind = models.CharField(max_length=80)
|
|
severity = models.CharField(
|
|
max_length=16, choices=FindingSeverity.choices, default=FindingSeverity.INFO
|
|
)
|
|
category = models.CharField(max_length=80)
|
|
location = models.CharField(max_length=255, blank=True)
|
|
description = models.TextField()
|
|
suggested_revision = models.TextField(blank=True)
|
|
evidence = models.JSONField(default=dict, blank=True)
|
|
status = models.CharField(
|
|
max_length=16, choices=FindingStatus.choices, default=FindingStatus.OPEN
|
|
)
|
|
model_metadata = models.JSONField(default=dict, blank=True)
|