Artifex/control_plane/authoring/models.py
2026-08-28 23:51:02 +07:00

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)