|
@@ -1,10 +1,11 @@
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
-from dataclasses import dataclass
|
|
|
|
|
|
|
+from dataclasses import dataclass, field
|
|
|
from datetime import datetime
|
|
from datetime import datetime
|
|
|
from enum import StrEnum
|
|
from enum import StrEnum
|
|
|
from typing import Any
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
+from .goal_coverage import MAX_DIRECTION_GOALS
|
|
|
from .phase_three_artifacts import RootDeliveryManifestV1
|
|
from .phase_three_artifacts import RootDeliveryManifestV1
|
|
|
from .phase_two_artifacts import (
|
|
from .phase_two_artifacts import (
|
|
|
CandidatePortfolioArtifactV1,
|
|
CandidatePortfolioArtifactV1,
|
|
@@ -40,41 +41,65 @@ class DirectionGoal:
|
|
|
goal_id: str
|
|
goal_id: str
|
|
|
statement: str
|
|
statement: str
|
|
|
rationale: str = ""
|
|
rationale: str = ""
|
|
|
|
|
+ parent_goal_id: str | None = None
|
|
|
|
|
+ success_criteria: tuple[str, ...] = ()
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
@dataclass(frozen=True, slots=True)
|
|
|
-class Criterion:
|
|
|
|
|
- criterion_id: str
|
|
|
|
|
- description: str
|
|
|
|
|
|
|
+class DirectionConstraint:
|
|
|
|
|
+ constraint_id: str
|
|
|
|
|
+ statement: str
|
|
|
|
|
+ rationale: str = ""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass(frozen=True, slots=True)
|
|
|
|
|
+class DirectionPreference:
|
|
|
|
|
+ preference_id: str
|
|
|
|
|
+ statement: str
|
|
|
|
|
+ rationale: str = ""
|
|
|
|
|
+ priority: int | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
@dataclass(frozen=True, slots=True)
|
|
|
-class ScriptDirectionArtifactV1:
|
|
|
|
|
|
|
+class DirectionArtifact:
|
|
|
goals: tuple[DirectionGoal, ...]
|
|
goals: tuple[DirectionGoal, ...]
|
|
|
- criteria: tuple[Criterion, ...] = ()
|
|
|
|
|
- domain_criteria: tuple[Criterion, ...] = ()
|
|
|
|
|
|
|
+ constraints: tuple[DirectionConstraint, ...] = ()
|
|
|
|
|
+ preferences: tuple[DirectionPreference, ...] = ()
|
|
|
topic_refs: tuple[str, ...] = ()
|
|
topic_refs: tuple[str, ...] = ()
|
|
|
persona_refs: tuple[str, ...] = ()
|
|
persona_refs: tuple[str, ...] = ()
|
|
|
strategy_refs: tuple[str, ...] = ()
|
|
strategy_refs: tuple[str, ...] = ()
|
|
|
evidence_refs: tuple[str, ...] = ()
|
|
evidence_refs: tuple[str, ...] = ()
|
|
|
- legacy_markdown: str = ""
|
|
|
|
|
|
|
+ legacy_markdown: str = field(init=False)
|
|
|
canonical_sha256: str = ""
|
|
canonical_sha256: str = ""
|
|
|
|
|
|
|
|
def __post_init__(self) -> None:
|
|
def __post_init__(self) -> None:
|
|
|
- if not 1 <= len(self.goals) <= 3:
|
|
|
|
|
- raise ValueError("a direction artifact must contain between one and three goals")
|
|
|
|
|
|
|
+ if not 1 <= len(self.goals) <= MAX_DIRECTION_GOALS:
|
|
|
|
|
+ raise ValueError("a direction artifact must contain between one and thirty goals")
|
|
|
_require_unique_text(
|
|
_require_unique_text(
|
|
|
((item.goal_id, item.statement) for item in self.goals),
|
|
((item.goal_id, item.statement) for item in self.goals),
|
|
|
label="direction goal",
|
|
label="direction goal",
|
|
|
)
|
|
)
|
|
|
_require_unique_text(
|
|
_require_unique_text(
|
|
|
- ((item.criterion_id, item.description) for item in self.criteria),
|
|
|
|
|
- label="direction criterion",
|
|
|
|
|
|
|
+ ((item.constraint_id, item.statement) for item in self.constraints),
|
|
|
|
|
+ label="direction constraint",
|
|
|
)
|
|
)
|
|
|
_require_unique_text(
|
|
_require_unique_text(
|
|
|
- ((item.criterion_id, item.description) for item in self.domain_criteria),
|
|
|
|
|
- label="domain criterion",
|
|
|
|
|
|
|
+ ((item.preference_id, item.statement) for item in self.preferences),
|
|
|
|
|
+ label="direction preference",
|
|
|
)
|
|
)
|
|
|
|
|
+ _validate_direction_goals(self.goals)
|
|
|
|
|
+ if len(self.constraints) + len(self.preferences) > 30:
|
|
|
|
|
+ raise ValueError("a direction artifact may contain at most thirty rules")
|
|
|
|
|
+ identifiers = [
|
|
|
|
|
+ *(item.goal_id for item in self.goals),
|
|
|
|
|
+ *(item.constraint_id for item in self.constraints),
|
|
|
|
|
+ *(item.preference_id for item in self.preferences),
|
|
|
|
|
+ ]
|
|
|
|
|
+ if len(set(identifiers)) != len(identifiers):
|
|
|
|
|
+ raise ValueError("direction item IDs must be unique across the artifact")
|
|
|
|
|
+ for item in self.preferences:
|
|
|
|
|
+ if item.priority is not None and item.priority not in {1, 2, 3}:
|
|
|
|
|
+ raise ValueError("direction preference priority must be 1, 2, or 3")
|
|
|
for label, refs in (
|
|
for label, refs in (
|
|
|
("topic", self.topic_refs),
|
|
("topic", self.topic_refs),
|
|
|
("persona", self.persona_refs),
|
|
("persona", self.persona_refs),
|
|
@@ -91,23 +116,37 @@ class ScriptDirectionArtifactV1:
|
|
|
raise ValueError("evidence references must use immutable business Artifact URIs")
|
|
raise ValueError("evidence references must use immutable business Artifact URIs")
|
|
|
if not self.evidence_refs:
|
|
if not self.evidence_refs:
|
|
|
raise ValueError("a direction artifact must cite accepted retrieval evidence")
|
|
raise ValueError("a direction artifact must cite accepted retrieval evidence")
|
|
|
- if not self.legacy_markdown.strip():
|
|
|
|
|
- raise ValueError("legacy direction markdown must not be blank")
|
|
|
|
|
|
|
+ object.__setattr__(self, "legacy_markdown", render_direction_markdown(self))
|
|
|
|
|
|
|
|
def content_payload(self) -> dict[str, Any]:
|
|
def content_payload(self) -> dict[str, Any]:
|
|
|
return {
|
|
return {
|
|
|
"schema_version": "script-direction/v1",
|
|
"schema_version": "script-direction/v1",
|
|
|
"goals": [
|
|
"goals": [
|
|
|
- {"goal_id": item.goal_id, "statement": item.statement, "rationale": item.rationale}
|
|
|
|
|
|
|
+ {
|
|
|
|
|
+ "goal_id": item.goal_id,
|
|
|
|
|
+ "parent_goal_id": item.parent_goal_id,
|
|
|
|
|
+ "statement": item.statement,
|
|
|
|
|
+ "rationale": item.rationale,
|
|
|
|
|
+ "success_criteria": list(item.success_criteria),
|
|
|
|
|
+ }
|
|
|
for item in self.goals
|
|
for item in self.goals
|
|
|
],
|
|
],
|
|
|
- "criteria": [
|
|
|
|
|
- {"criterion_id": item.criterion_id, "description": item.description}
|
|
|
|
|
- for item in self.criteria
|
|
|
|
|
|
|
+ "constraints": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "constraint_id": item.constraint_id,
|
|
|
|
|
+ "statement": item.statement,
|
|
|
|
|
+ "rationale": item.rationale,
|
|
|
|
|
+ }
|
|
|
|
|
+ for item in self.constraints
|
|
|
],
|
|
],
|
|
|
- "domain_criteria": [
|
|
|
|
|
- {"criterion_id": item.criterion_id, "description": item.description}
|
|
|
|
|
- for item in self.domain_criteria
|
|
|
|
|
|
|
+ "preferences": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "preference_id": item.preference_id,
|
|
|
|
|
+ "statement": item.statement,
|
|
|
|
|
+ "rationale": item.rationale,
|
|
|
|
|
+ "priority": item.priority,
|
|
|
|
|
+ }
|
|
|
|
|
+ for item in self.preferences
|
|
|
],
|
|
],
|
|
|
"topic_refs": list(self.topic_refs),
|
|
"topic_refs": list(self.topic_refs),
|
|
|
"persona_refs": list(self.persona_refs),
|
|
"persona_refs": list(self.persona_refs),
|
|
@@ -174,7 +213,7 @@ class EvidenceRecordV1:
|
|
|
|
|
|
|
|
BusinessArtifact = (
|
|
BusinessArtifact = (
|
|
|
EvidenceRecordV1
|
|
EvidenceRecordV1
|
|
|
- | ScriptDirectionArtifactV1
|
|
|
|
|
|
|
+ | DirectionArtifact
|
|
|
| StructureArtifactV1
|
|
| StructureArtifactV1
|
|
|
| ParagraphArtifactV1
|
|
| ParagraphArtifactV1
|
|
|
| ElementSetArtifactV1
|
|
| ElementSetArtifactV1
|
|
@@ -212,3 +251,79 @@ def _require_unique_text(
|
|
|
identifiers.append(identifier)
|
|
identifiers.append(identifier)
|
|
|
if len(set(identifiers)) != len(identifiers):
|
|
if len(set(identifiers)) != len(identifiers):
|
|
|
raise ValueError(f"{label} IDs must be unique")
|
|
raise ValueError(f"{label} IDs must be unique")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _validate_direction_goals(goals: tuple[DirectionGoal, ...]) -> None:
|
|
|
|
|
+ by_id = {item.goal_id: item for item in goals}
|
|
|
|
|
+ for item in goals:
|
|
|
|
|
+ if not item.rationale.strip():
|
|
|
|
|
+ raise ValueError("every direction goal requires a rationale")
|
|
|
|
|
+ if not item.success_criteria:
|
|
|
|
|
+ raise ValueError("every direction goal requires success criteria")
|
|
|
|
|
+ if len(item.success_criteria) > 8:
|
|
|
|
|
+ raise ValueError("a direction goal may contain at most eight success criteria")
|
|
|
|
|
+ if any(not value.strip() for value in item.success_criteria):
|
|
|
|
|
+ raise ValueError("direction goal success criteria must not be blank")
|
|
|
|
|
+ if len(set(item.success_criteria)) != len(item.success_criteria):
|
|
|
|
|
+ raise ValueError("direction goal success criteria must be unique")
|
|
|
|
|
+ parent_id = item.parent_goal_id
|
|
|
|
|
+ if parent_id is None:
|
|
|
|
|
+ continue
|
|
|
|
|
+ if parent_id == item.goal_id:
|
|
|
|
|
+ raise ValueError("a direction goal cannot parent itself")
|
|
|
|
|
+ parent = by_id.get(parent_id)
|
|
|
|
|
+ if parent is None:
|
|
|
|
|
+ raise ValueError("a direction goal parent must exist in the same artifact")
|
|
|
|
|
+ if parent.parent_goal_id is not None:
|
|
|
|
|
+ raise ValueError("direction goal hierarchy may contain only two levels")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def render_direction_markdown(direction: DirectionArtifact) -> str:
|
|
|
|
|
+ children: dict[str, list[DirectionGoal]] = {}
|
|
|
|
|
+ for goal in direction.goals:
|
|
|
|
|
+ if goal.parent_goal_id is not None:
|
|
|
|
|
+ children.setdefault(goal.parent_goal_id, []).append(goal)
|
|
|
|
|
+
|
|
|
|
|
+ lines = ["# Direction", "", "## Goals"]
|
|
|
|
|
+
|
|
|
|
|
+ def append_goal(goal: DirectionGoal, *, level: int) -> None:
|
|
|
|
|
+ lines.extend(["", f"{'#' * level} {goal.goal_id}: {goal.statement}"])
|
|
|
|
|
+ if goal.rationale:
|
|
|
|
|
+ lines.extend(["", f"**Rationale:** {goal.rationale}"])
|
|
|
|
|
+ lines.extend(["", "**Success criteria:**"])
|
|
|
|
|
+ lines.extend(f"- {criterion}" for criterion in goal.success_criteria)
|
|
|
|
|
+
|
|
|
|
|
+ for goal in direction.goals:
|
|
|
|
|
+ if goal.parent_goal_id is not None:
|
|
|
|
|
+ continue
|
|
|
|
|
+ append_goal(goal, level=3)
|
|
|
|
|
+ for child in children.get(goal.goal_id, []):
|
|
|
|
|
+ append_goal(child, level=4)
|
|
|
|
|
+
|
|
|
|
|
+ if direction.constraints:
|
|
|
|
|
+ lines.extend(["", "## Constraints"])
|
|
|
|
|
+ for constraint in direction.constraints:
|
|
|
|
|
+ suffix = f" — {constraint.rationale}" if constraint.rationale else ""
|
|
|
|
|
+ lines.append(f"- **{constraint.constraint_id}:** {constraint.statement}{suffix}")
|
|
|
|
|
+ if direction.preferences:
|
|
|
|
|
+ lines.extend(["", "## Preferences"])
|
|
|
|
|
+ for preference in direction.preferences:
|
|
|
|
|
+ priority = (
|
|
|
|
|
+ f" (priority {preference.priority})" if preference.priority is not None else ""
|
|
|
|
|
+ )
|
|
|
|
|
+ suffix = f" — {preference.rationale}" if preference.rationale else ""
|
|
|
|
|
+ lines.append(
|
|
|
|
|
+ f"- **{preference.preference_id}{priority}:** {preference.statement}{suffix}"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ references = (
|
|
|
|
|
+ ("Topic", direction.topic_refs),
|
|
|
|
|
+ ("Persona", direction.persona_refs),
|
|
|
|
|
+ ("Strategy", direction.strategy_refs),
|
|
|
|
|
+ ("Evidence", direction.evidence_refs),
|
|
|
|
|
+ )
|
|
|
|
|
+ lines.extend(["", "## References"])
|
|
|
|
|
+ for label, values in references:
|
|
|
|
|
+ for value in values:
|
|
|
|
|
+ lines.append(f"- **{label}:** {value}")
|
|
|
|
|
+ return "\n".join(lines).strip()
|