|
|
@@ -3,16 +3,42 @@
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import re
|
|
|
-from collections.abc import Mapping
|
|
|
+from collections.abc import Mapping, Sequence
|
|
|
+from dataclasses import dataclass
|
|
|
from typing import Any
|
|
|
|
|
|
-from .phase_two_artifacts import ComparisonArtifactV1
|
|
|
+from .errors import ScriptBuildError
|
|
|
+from .phase_two_artifacts import (
|
|
|
+ ComparisonArtifactV1,
|
|
|
+ ElementSetArtifactV1,
|
|
|
+ ParagraphArtifactV1,
|
|
|
+ ScriptElementV1,
|
|
|
+ ScriptParagraphElementLinkV1,
|
|
|
+ ScriptParagraphV1,
|
|
|
+ StructureArtifactV1,
|
|
|
+ StructuredScriptArtifactV1,
|
|
|
+)
|
|
|
|
|
|
_PLACEHOLDER = re.compile(
|
|
|
r"(?:TODO|TBD|\[\s*待补|<placeholder>|后续(?:应|需|将)产出|"
|
|
|
r"此处插入|此处(?:描述|说明|展示|呈现).{0,12}(?:存在|内容|信息)?)",
|
|
|
re.IGNORECASE,
|
|
|
)
|
|
|
+_META_PROSE = re.compile(
|
|
|
+ r"(?:本段|这一段|该段)(?:将|会|主要|旨在|用于)|"
|
|
|
+ r"(?:this|the)\s+(?:paragraph|section)\s+(?:will|should|aims?\s+to)",
|
|
|
+ re.IGNORECASE,
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True, slots=True)
|
|
|
+class ContentIssue:
|
|
|
+ path: str
|
|
|
+ message: str
|
|
|
+
|
|
|
+
|
|
|
+class ScriptContentError(ScriptBuildError):
|
|
|
+ pass
|
|
|
|
|
|
|
|
|
def placeholder_paths(artifact: Any) -> tuple[str, ...]:
|
|
|
@@ -35,6 +61,266 @@ def placeholder_paths(artifact: Any) -> tuple[str, ...]:
|
|
|
return tuple(findings)
|
|
|
|
|
|
|
|
|
+def candidate_content_issues(artifact: Any) -> tuple[ContentIssue, ...]:
|
|
|
+ """Validate the content actually produced by one creative candidate."""
|
|
|
+
|
|
|
+ if isinstance(artifact, StructureArtifactV1):
|
|
|
+ return _paragraph_shape_issues(artifact.paragraphs)
|
|
|
+ if isinstance(artifact, ParagraphArtifactV1):
|
|
|
+ target_ids = _changed_ids(artifact.change_manifest, "paragraph")
|
|
|
+ if not artifact.change_manifest:
|
|
|
+ target_ids = {item.paragraph_id for item in artifact.paragraphs if item.is_active}
|
|
|
+ targets = tuple(
|
|
|
+ item
|
|
|
+ for item in artifact.paragraphs
|
|
|
+ if item.is_active and item.paragraph_id in target_ids
|
|
|
+ )
|
|
|
+ if not targets:
|
|
|
+ return (ContentIssue("paragraphs", "Paragraph candidate changes no active paragraph"),)
|
|
|
+ leaf_ids = _leaf_ids(artifact.paragraphs)
|
|
|
+ leaves = tuple(item for item in targets if item.paragraph_id in leaf_ids)
|
|
|
+ if not leaves:
|
|
|
+ return (ContentIssue("paragraphs", "Paragraph candidate realizes no leaf paragraph"),)
|
|
|
+ return tuple(
|
|
|
+ issue
|
|
|
+ for paragraph in leaves
|
|
|
+ for issue in _leaf_prose_issues(paragraph, f"paragraphs[{paragraph.paragraph_id}]")
|
|
|
+ )
|
|
|
+ if isinstance(artifact, ElementSetArtifactV1):
|
|
|
+ element_ids = _changed_ids(artifact.change_manifest, "element")
|
|
|
+ if not artifact.change_manifest:
|
|
|
+ element_ids = {item.element_id for item in artifact.elements if item.is_active}
|
|
|
+ target_elements = tuple(
|
|
|
+ item
|
|
|
+ for item in artifact.elements
|
|
|
+ if item.is_active and item.element_id in element_ids
|
|
|
+ )
|
|
|
+ if not target_elements:
|
|
|
+ return (ContentIssue("elements", "ElementSet changes no active Element"),)
|
|
|
+ linked = {item.element_id for item in artifact.paragraph_element_links}
|
|
|
+ return tuple(
|
|
|
+ ContentIssue(
|
|
|
+ f"elements[{item.element_id}]",
|
|
|
+ "ElementSet Element is not linked to a Paragraph",
|
|
|
+ )
|
|
|
+ for item in target_elements
|
|
|
+ if item.element_id not in linked
|
|
|
+ )
|
|
|
+ if isinstance(artifact, StructuredScriptArtifactV1):
|
|
|
+ return complete_script_issues(
|
|
|
+ artifact.paragraphs,
|
|
|
+ artifact.elements,
|
|
|
+ artifact.paragraph_element_links,
|
|
|
+ )
|
|
|
+ return ()
|
|
|
+
|
|
|
+
|
|
|
+def artifact_realizes_goals(artifact: Any) -> bool:
|
|
|
+ """Only completed Paragraph and ElementSet candidates realize Direction Goals."""
|
|
|
+
|
|
|
+ return isinstance(artifact, (ParagraphArtifactV1, ElementSetArtifactV1)) and not (
|
|
|
+ candidate_content_issues(artifact) or placeholder_paths(artifact)
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def script_source_issues(artifacts: Sequence[Any]) -> tuple[ContentIssue, ...]:
|
|
|
+ """Validate the adopted creative source set behind one formal script."""
|
|
|
+
|
|
|
+ issues: list[ContentIssue] = []
|
|
|
+ for label, artifact_type in (
|
|
|
+ ("Structure", StructureArtifactV1),
|
|
|
+ ("Paragraph", ParagraphArtifactV1),
|
|
|
+ ("ElementSet", ElementSetArtifactV1),
|
|
|
+ ):
|
|
|
+ if not any(isinstance(item, artifact_type) for item in artifacts):
|
|
|
+ issues.append(ContentIssue("source_artifact_refs", f"missing {label} source"))
|
|
|
+ for index, artifact in enumerate(artifacts):
|
|
|
+ if not isinstance(
|
|
|
+ artifact,
|
|
|
+ (StructureArtifactV1, ParagraphArtifactV1, ElementSetArtifactV1),
|
|
|
+ ):
|
|
|
+ continue
|
|
|
+ issues.extend(
|
|
|
+ ContentIssue(f"sources[{index}].{item.path}", item.message)
|
|
|
+ for item in candidate_content_issues(artifact)
|
|
|
+ )
|
|
|
+ issues.extend(
|
|
|
+ ContentIssue(f"sources[{index}].{path}", "source contains a placeholder")
|
|
|
+ for path in placeholder_paths(artifact)
|
|
|
+ )
|
|
|
+ return tuple(issues)
|
|
|
+
|
|
|
+
|
|
|
+def require_complete_sources(artifacts: Sequence[Any]) -> None:
|
|
|
+ issues = script_source_issues(artifacts)
|
|
|
+ if issues:
|
|
|
+ summary = "; ".join(f"{item.path}: {item.message}" for item in issues[:8])
|
|
|
+ raise ScriptContentError("SCRIPT_CONTENT_INCOMPLETE", summary)
|
|
|
+
|
|
|
+
|
|
|
+def complete_script_issues(
|
|
|
+ paragraphs: tuple[ScriptParagraphV1, ...],
|
|
|
+ elements: tuple[ScriptElementV1, ...],
|
|
|
+ links: tuple[ScriptParagraphElementLinkV1, ...],
|
|
|
+) -> tuple[ContentIssue, ...]:
|
|
|
+ """Return every hard violation of the formal script delivery contract."""
|
|
|
+
|
|
|
+ issues = list(_paragraph_shape_issues(paragraphs))
|
|
|
+ active_paragraphs = tuple(item for item in paragraphs if item.is_active)
|
|
|
+ active_elements = tuple(item for item in elements if item.is_active)
|
|
|
+ if not active_paragraphs:
|
|
|
+ issues.append(ContentIssue("paragraphs", "Script has no active Paragraph"))
|
|
|
+ leaf_ids = _leaf_ids(paragraphs)
|
|
|
+ for paragraph in active_paragraphs:
|
|
|
+ if paragraph.paragraph_id in leaf_ids:
|
|
|
+ issues.extend(
|
|
|
+ _leaf_prose_issues(paragraph, f"paragraphs[{paragraph.paragraph_id}]")
|
|
|
+ )
|
|
|
+
|
|
|
+ if not active_elements:
|
|
|
+ issues.append(ContentIssue("elements", "Script has no independent Element"))
|
|
|
+ active_paragraph_ids = {item.paragraph_id for item in active_paragraphs}
|
|
|
+ active_element_ids = {item.element_id for item in active_elements}
|
|
|
+ valid_links = tuple(
|
|
|
+ item
|
|
|
+ for item in links
|
|
|
+ if item.paragraph_id in active_paragraph_ids
|
|
|
+ and item.element_id in active_element_ids
|
|
|
+ )
|
|
|
+ if not valid_links:
|
|
|
+ issues.append(ContentIssue("paragraph_element_links", "Script has no active Element links"))
|
|
|
+ for index, link in enumerate(links):
|
|
|
+ if link not in valid_links:
|
|
|
+ issues.append(
|
|
|
+ ContentIssue(
|
|
|
+ f"paragraph_element_links[{index}]",
|
|
|
+ "Link must connect an active Paragraph and active Element",
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ names: dict[str, int] = {}
|
|
|
+ dimensions: dict[tuple[str, str], int] = {}
|
|
|
+ for element in active_elements:
|
|
|
+ name = _normalized(element.name)
|
|
|
+ dimension = (
|
|
|
+ _normalized(element.dimension_primary),
|
|
|
+ _normalized(element.dimension_secondary),
|
|
|
+ )
|
|
|
+ if name in names:
|
|
|
+ issues.append(
|
|
|
+ ContentIssue(
|
|
|
+ f"elements[{element.element_id}].name",
|
|
|
+ f"Element name duplicates element {names[name]}",
|
|
|
+ )
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ names[name] = element.element_id
|
|
|
+ if dimension in dimensions:
|
|
|
+ issues.append(
|
|
|
+ ContentIssue(
|
|
|
+ f"elements[{element.element_id}].dimension",
|
|
|
+ f"Element dimension duplicates element {dimensions[dimension]}",
|
|
|
+ )
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ dimensions[dimension] = element.element_id
|
|
|
+
|
|
|
+ linked_paragraphs = {item.paragraph_id for item in valid_links}
|
|
|
+ linked_elements = {item.element_id for item in valid_links}
|
|
|
+ for paragraph_id in sorted(leaf_ids - linked_paragraphs):
|
|
|
+ issues.append(
|
|
|
+ ContentIssue(
|
|
|
+ f"paragraphs[{paragraph_id}]",
|
|
|
+ "Leaf Paragraph is not linked to an independent Element",
|
|
|
+ )
|
|
|
+ )
|
|
|
+ for element in active_elements:
|
|
|
+ if element.element_id not in linked_elements:
|
|
|
+ issues.append(
|
|
|
+ ContentIssue(
|
|
|
+ f"elements[{element.element_id}]",
|
|
|
+ "Active Element is not linked to a Paragraph",
|
|
|
+ )
|
|
|
+ )
|
|
|
+ return tuple(issues)
|
|
|
+
|
|
|
+
|
|
|
+def require_complete_script(
|
|
|
+ paragraphs: tuple[ScriptParagraphV1, ...],
|
|
|
+ elements: tuple[ScriptElementV1, ...],
|
|
|
+ links: tuple[ScriptParagraphElementLinkV1, ...],
|
|
|
+) -> None:
|
|
|
+ issues = complete_script_issues(paragraphs, elements, links)
|
|
|
+ if issues:
|
|
|
+ summary = "; ".join(f"{item.path}: {item.message}" for item in issues[:8])
|
|
|
+ raise ScriptContentError("SCRIPT_CONTENT_INCOMPLETE", summary)
|
|
|
+
|
|
|
+
|
|
|
+def _paragraph_shape_issues(
|
|
|
+ paragraphs: tuple[ScriptParagraphV1, ...],
|
|
|
+) -> tuple[ContentIssue, ...]:
|
|
|
+ issues: list[ContentIssue] = []
|
|
|
+ active_ids = {item.paragraph_id for item in paragraphs if item.is_active}
|
|
|
+ atom_fields = (
|
|
|
+ "theme_elements",
|
|
|
+ "form_elements",
|
|
|
+ "function_elements",
|
|
|
+ "feeling_elements",
|
|
|
+ )
|
|
|
+ for paragraph in paragraphs:
|
|
|
+ if not paragraph.is_active:
|
|
|
+ continue
|
|
|
+ path = f"paragraphs[{paragraph.paragraph_id}]"
|
|
|
+ if not paragraph.content_range:
|
|
|
+ issues.append(ContentIssue(f"{path}.content_range", "content_range is empty"))
|
|
|
+ if paragraph.parent_id is not None and paragraph.parent_id not in active_ids:
|
|
|
+ issues.append(ContentIssue(f"{path}.parent_id", "active parent is missing"))
|
|
|
+ for field in atom_fields:
|
|
|
+ values = getattr(paragraph, field)
|
|
|
+ if not values or any(not item for item in values):
|
|
|
+ issues.append(ContentIssue(f"{path}.{field}", "atom array is empty"))
|
|
|
+ return tuple(issues)
|
|
|
+
|
|
|
+
|
|
|
+def _leaf_prose_issues(paragraph: ScriptParagraphV1, path: str) -> tuple[ContentIssue, ...]:
|
|
|
+ issues: list[ContentIssue] = []
|
|
|
+ for field in ("theme", "form", "function", "feeling", "description", "full_description"):
|
|
|
+ value = getattr(paragraph, field)
|
|
|
+ if not isinstance(value, str) or not value.strip():
|
|
|
+ issues.append(ContentIssue(f"{path}.{field}", "required prose is empty"))
|
|
|
+ elif _PLACEHOLDER.search(value) or _META_PROSE.search(value):
|
|
|
+ issues.append(
|
|
|
+ ContentIssue(f"{path}.{field}", "required prose is a placeholder or metadata")
|
|
|
+ )
|
|
|
+ return tuple(issues)
|
|
|
+
|
|
|
+
|
|
|
+def _leaf_ids(paragraphs: tuple[ScriptParagraphV1, ...]) -> set[int]:
|
|
|
+ active = {item.paragraph_id for item in paragraphs if item.is_active}
|
|
|
+ parents = {
|
|
|
+ item.parent_id
|
|
|
+ for item in paragraphs
|
|
|
+ if item.is_active and item.parent_id is not None and item.parent_id in active
|
|
|
+ }
|
|
|
+ return active - parents
|
|
|
+
|
|
|
+
|
|
|
+def _changed_ids(manifest: Mapping[str, Any], entity: str) -> set[int]:
|
|
|
+ plural = f"{entity}_ids"
|
|
|
+ created = manifest.get("created")
|
|
|
+ updated = manifest.get("updated")
|
|
|
+ values: list[Any] = []
|
|
|
+ if isinstance(created, Mapping):
|
|
|
+ values.extend(created.get(plural) or ())
|
|
|
+ if isinstance(updated, Mapping):
|
|
|
+ values.extend(updated.get(plural) or ())
|
|
|
+ return {int(item) for item in values if isinstance(item, int) and not isinstance(item, bool)}
|
|
|
+
|
|
|
+
|
|
|
+def _normalized(value: str) -> str:
|
|
|
+ return " ".join(value.split()).casefold()
|
|
|
+
|
|
|
+
|
|
|
def comparison_fairness_errors(
|
|
|
artifact: ComparisonArtifactV1,
|
|
|
expected_criterion_ids: set[str] | None = None,
|
|
|
@@ -68,4 +354,15 @@ def comparison_fairness_errors(
|
|
|
return tuple(errors)
|
|
|
|
|
|
|
|
|
-__all__ = ["comparison_fairness_errors", "placeholder_paths"]
|
|
|
+__all__ = [
|
|
|
+ "ContentIssue",
|
|
|
+ "ScriptContentError",
|
|
|
+ "artifact_realizes_goals",
|
|
|
+ "candidate_content_issues",
|
|
|
+ "comparison_fairness_errors",
|
|
|
+ "complete_script_issues",
|
|
|
+ "placeholder_paths",
|
|
|
+ "require_complete_script",
|
|
|
+ "require_complete_sources",
|
|
|
+ "script_source_issues",
|
|
|
+]
|