artifacts.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. """Recursive Validator 的最小只读产物协议。
  2. 框架只保存不可变 ``ArtifactRef``,并通过应用注入的 ``ArtifactResolver``
  3. 读取当前任务获授权的真实材料;上传、ACL、索引和生命周期不属于本模块。
  4. """
  5. from __future__ import annotations
  6. from collections.abc import Mapping, Sequence
  7. from hashlib import sha256
  8. import json
  9. from typing import Any, Literal, Protocol, runtime_checkable
  10. from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
  11. class _StrictModel(BaseModel):
  12. model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
  13. class ArtifactRef(_StrictModel):
  14. """任务报告或标准 Tool Result 中的不可变产物句柄。"""
  15. artifact_id: str = Field(min_length=1, max_length=500)
  16. version: str = Field(min_length=1, max_length=200)
  17. content_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
  18. kind: str = Field(min_length=1, max_length=100)
  19. mime_type: str | None = Field(default=None, max_length=200)
  20. class ValidationMaterial(_StrictModel):
  21. """Resolver 返回、可直接放入 Validator packet 的只读材料。"""
  22. artifact_id: str = Field(min_length=1, max_length=500)
  23. version: str = Field(min_length=1, max_length=200)
  24. content_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
  25. kind: str = Field(min_length=1, max_length=100)
  26. mime_type: str | None = Field(default=None, max_length=200)
  27. root_trace_id: str = Field(min_length=1)
  28. uid: str | None = None
  29. content: Any
  30. @field_validator("content")
  31. @classmethod
  32. def validate_content(cls, value: Any) -> Any:
  33. canonical_material_content(value)
  34. return value
  35. def to_ref(self) -> ArtifactRef:
  36. return ArtifactRef(
  37. artifact_id=self.artifact_id,
  38. version=self.version,
  39. content_hash=self.content_hash,
  40. kind=self.kind,
  41. mime_type=self.mime_type,
  42. )
  43. @runtime_checkable
  44. class ArtifactResolver(Protocol):
  45. """由业务应用实现的可信、只读产物解析端口。"""
  46. async def resolve(
  47. self,
  48. ref: ArtifactRef,
  49. root_trace_id: str,
  50. uid: str | None,
  51. ) -> ValidationMaterial:
  52. ...
  53. class ArtifactResolutionError(RuntimeError):
  54. """带确定性语义的产物解析错误。"""
  55. def __init__(
  56. self,
  57. outcome: Literal["failed", "unknown", "error"],
  58. message: str,
  59. ) -> None:
  60. super().__init__(message)
  61. self.outcome = outcome
  62. class ArtifactNotFound(ArtifactResolutionError):
  63. def __init__(self, message: str = "Artifact does not exist") -> None:
  64. super().__init__("failed", message)
  65. class ArtifactTemporarilyUnavailable(ArtifactResolutionError):
  66. def __init__(self, message: str = "Artifact resolver is unavailable") -> None:
  67. super().__init__("unknown", message)
  68. class ArtifactAccessDenied(ArtifactResolutionError):
  69. def __init__(self, message: str = "Artifact is not authorized") -> None:
  70. super().__init__("error", message)
  71. class MaterialIssue(_StrictModel):
  72. """框架在 LLM 调用前产生的材料确定性检查结果。"""
  73. artifact_id: str
  74. outcome: Literal["failed", "unknown", "error"]
  75. reason: str = Field(min_length=1)
  76. scopes: list[Literal["evidence", "hypothesis", "output", "task", "root"]] = (
  77. Field(default_factory=lambda: [
  78. "evidence", "hypothesis", "output", "task", "root",
  79. ], min_length=1)
  80. )
  81. @field_validator("scopes")
  82. @classmethod
  83. def normalize_scopes(cls, value: list[str]) -> list[str]:
  84. order = ("evidence", "hypothesis", "output", "task", "root")
  85. selected = set(value)
  86. return [scope for scope in order if scope in selected]
  87. def canonical_material_content(content: Any) -> str:
  88. """稳定序列化材料内容,供字符计数和 SHA-256 校验共用。"""
  89. if isinstance(content, str):
  90. return content
  91. try:
  92. return json.dumps(
  93. content,
  94. ensure_ascii=False,
  95. sort_keys=True,
  96. separators=(",", ":"),
  97. allow_nan=False,
  98. )
  99. except (TypeError, ValueError) as exc:
  100. raise ValueError(f"Artifact content must be JSON serializable: {exc}") from exc
  101. def material_content_hash(content: Any) -> str:
  102. return sha256(canonical_material_content(content).encode("utf-8")).hexdigest()
  103. def material_chars(material: ValidationMaterial) -> int:
  104. return len(canonical_material_content(material.content))
  105. def extract_artifact_refs(value: Any) -> list[ArtifactRef]:
  106. """只读取标准对象顶层 ``artifact_refs``,非法元数据直接失败关闭。"""
  107. if isinstance(value, BaseModel):
  108. value = value.model_dump(mode="json")
  109. if not isinstance(value, Mapping) or "artifact_refs" not in value:
  110. return []
  111. raw_refs = value.get("artifact_refs")
  112. if not isinstance(raw_refs, Sequence) or isinstance(
  113. raw_refs, (str, bytes, bytearray)
  114. ):
  115. raise ValueError("artifact_refs must be an array")
  116. found = [ArtifactRef.model_validate(raw) for raw in raw_refs]
  117. unique: dict[tuple[str, str, str], ArtifactRef] = {}
  118. for ref in found:
  119. unique[(ref.artifact_id, ref.version, ref.content_hash)] = ref
  120. return list(unique.values())
  121. async def resolve_artifact_refs(
  122. refs: Sequence[ArtifactRef],
  123. *,
  124. resolver: ArtifactResolver | None,
  125. root_trace_id: str,
  126. uid: str | None,
  127. ) -> tuple[list[ValidationMaterial], list[MaterialIssue]]:
  128. """逐个解析引用并把缺失、暂不可用和越权映射为稳定结果。"""
  129. materials: list[ValidationMaterial] = []
  130. issues: list[MaterialIssue] = []
  131. for ref in refs:
  132. issue_scopes = _artifact_validation_scopes(ref.kind)
  133. if resolver is None:
  134. issues.append(MaterialIssue(
  135. artifact_id=ref.artifact_id,
  136. outcome="unknown",
  137. reason="No ArtifactResolver is configured for this artifact",
  138. scopes=issue_scopes,
  139. ))
  140. continue
  141. try:
  142. material = ValidationMaterial.model_validate(
  143. await resolver.resolve(ref, root_trace_id, uid)
  144. )
  145. if material.root_trace_id != root_trace_id or material.uid != uid:
  146. raise ArtifactAccessDenied(
  147. "Resolved artifact belongs to another task tree or owner"
  148. )
  149. if material.to_ref() != ref:
  150. raise ArtifactAccessDenied(
  151. "Resolved artifact metadata does not match the authorized reference"
  152. )
  153. if material_content_hash(material.content) != ref.content_hash:
  154. raise ArtifactNotFound("Resolved artifact content hash does not match")
  155. materials.append(material)
  156. except ArtifactResolutionError as exc:
  157. issues.append(MaterialIssue(
  158. artifact_id=ref.artifact_id,
  159. outcome=exc.outcome,
  160. reason=str(exc),
  161. scopes=issue_scopes,
  162. ))
  163. except (ValidationError, TypeError, ValueError) as exc:
  164. issues.append(MaterialIssue(
  165. artifact_id=ref.artifact_id,
  166. outcome="error",
  167. reason=f"Artifact resolver returned invalid data: {exc}",
  168. scopes=issue_scopes,
  169. ))
  170. except Exception as exc:
  171. issues.append(MaterialIssue(
  172. artifact_id=ref.artifact_id,
  173. outcome="unknown",
  174. reason=f"Artifact resolver failed: {exc}",
  175. scopes=issue_scopes,
  176. ))
  177. return materials, issues
  178. def _artifact_validation_scopes(
  179. kind: str,
  180. ) -> list[Literal["evidence", "hypothesis", "output", "task", "root"]]:
  181. """按产物类型限制确定性失败的影响面,未受影响 Scope 仍继续验收。"""
  182. normalized = kind.strip().lower()
  183. if any(marker in normalized for marker in ("evidence", "source", "reference")):
  184. return ["evidence", "hypothesis", "task", "root"]
  185. return ["output", "task", "root"]