فهرست منبع

方向合同:升级层级目标、硬约束与软偏好表达

将 Direction 改为无版本号的结构化领域模型,支持一层父子 Goal、每个 Goal 的成功标准、硬约束和带优先级的软偏好。

取消 1–3 个目标限制,将全部目标节点上限设为 30;由 Host 确定性生成 legacy_markdown,并校验层级、标识符和引用完整性。
SamLee 23 ساعت پیش
والد
کامیت
daa789032e

+ 8 - 4
script_build_host/src/script_build_host/domain/__init__.py

@@ -2,12 +2,14 @@ from .artifacts import (
     ArtifactKind,
     ArtifactState,
     ArtifactVersion,
-    Criterion,
+    DirectionArtifact,
+    DirectionConstraint,
     DirectionGoal,
+    DirectionPreference,
     EvidenceRecordV1,
-    ScriptDirectionArtifactV1,
 )
 from .errors import ScriptBuildError
+from .goal_coverage import GoalCoverage
 from .input_snapshot import ScriptBuildInput, ScriptBuildInputSnapshotV1
 from .records import BuildStatus, MissionBinding, Principal, Publication
 
@@ -16,14 +18,16 @@ __all__ = [
     "ArtifactState",
     "ArtifactVersion",
     "BuildStatus",
-    "Criterion",
+    "DirectionArtifact",
+    "DirectionConstraint",
     "DirectionGoal",
+    "DirectionPreference",
     "EvidenceRecordV1",
+    "GoalCoverage",
     "MissionBinding",
     "Principal",
     "Publication",
     "ScriptBuildError",
     "ScriptBuildInput",
     "ScriptBuildInputSnapshotV1",
-    "ScriptDirectionArtifactV1",
 ]

+ 139 - 24
script_build_host/src/script_build_host/domain/artifacts.py

@@ -1,10 +1,11 @@
 from __future__ import annotations
 
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from datetime import datetime
 from enum import StrEnum
 from typing import Any
 
+from .goal_coverage import MAX_DIRECTION_GOALS
 from .phase_three_artifacts import RootDeliveryManifestV1
 from .phase_two_artifacts import (
     CandidatePortfolioArtifactV1,
@@ -40,41 +41,65 @@ class DirectionGoal:
     goal_id: str
     statement: str
     rationale: str = ""
+    parent_goal_id: str | None = None
+    success_criteria: tuple[str, ...] = ()
 
 
 @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)
-class ScriptDirectionArtifactV1:
+class DirectionArtifact:
     goals: tuple[DirectionGoal, ...]
-    criteria: tuple[Criterion, ...] = ()
-    domain_criteria: tuple[Criterion, ...] = ()
+    constraints: tuple[DirectionConstraint, ...] = ()
+    preferences: tuple[DirectionPreference, ...] = ()
     topic_refs: tuple[str, ...] = ()
     persona_refs: tuple[str, ...] = ()
     strategy_refs: tuple[str, ...] = ()
     evidence_refs: tuple[str, ...] = ()
-    legacy_markdown: str = ""
+    legacy_markdown: str = field(init=False)
     canonical_sha256: str = ""
 
     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(
             ((item.goal_id, item.statement) for item in self.goals),
             label="direction goal",
         )
         _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(
-            ((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 (
             ("topic", self.topic_refs),
             ("persona", self.persona_refs),
@@ -91,23 +116,37 @@ class ScriptDirectionArtifactV1:
             raise ValueError("evidence references must use immutable business Artifact URIs")
         if not self.evidence_refs:
             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]:
         return {
             "schema_version": "script-direction/v1",
             "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
             ],
-            "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),
             "persona_refs": list(self.persona_refs),
@@ -174,7 +213,7 @@ class EvidenceRecordV1:
 
 BusinessArtifact = (
     EvidenceRecordV1
-    | ScriptDirectionArtifactV1
+    | DirectionArtifact
     | StructureArtifactV1
     | ParagraphArtifactV1
     | ElementSetArtifactV1
@@ -212,3 +251,79 @@ def _require_unique_text(
         identifiers.append(identifier)
     if len(set(identifiers)) != len(identifiers):
         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()