contracts.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. """Skill contract loading for the formal decode-content workflow."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. from dataclasses import dataclass
  6. from pathlib import Path
  7. from typing import Any, Iterable
  8. from core.prompts import PROMPTS_DIR
  9. from decode_content.models import ContractSnapshot
  10. ROOT = Path(__file__).resolve().parent.parent
  11. DEFAULT_SKILL_DIR = ROOT / "创作知识提取-skill"
  12. SCOPE_TREE_DIR = ROOT / "scope_trees"
  13. KNOWLEDGE_TYPES = ("how", "what", "why")
  14. BUSINESS_STAGES = ("灵感", "选题", "脚本")
  15. CREATION_STAGES = ("定向", "构思", "结构", "成文", "打磨")
  16. WHAT_KINDS = ("子集", "多维关系", "序列")
  17. SCOPE_TYPES = ("substance", "form", "feeling", "effect", "intent")
  18. REUSE_THRESHOLD = 0.90
  19. SCOPE_TYPE_CN = {
  20. "substance": "实质",
  21. "form": "形式",
  22. "feeling": "感受",
  23. "effect": "作用",
  24. "intent": "意图",
  25. }
  26. DIM_CREATIONS = ("创作",)
  27. DIM_ATTRIBUTE_BY_TYPE = {"how": "how工序", "what": "what构成", "why": "why原理"}
  28. CUSTOM_EXT_KEYS_BY_TYPE = {
  29. "how": ("业务阶段", "创作阶段", "动作", "出自"),
  30. "what": ("业务阶段", "概要", "出自"),
  31. "why": ("业务阶段", "出自"),
  32. }
  33. PROMPT_NAMES = (
  34. "gate_admit",
  35. "gate_refute",
  36. "gate_tiebreak",
  37. "normalize_scope",
  38. "gate_how_admit",
  39. "gate_how_refute",
  40. "gate_how_tiebreak",
  41. "gate_why_refute",
  42. )
  43. SCOPE_TREE_FILES = ("trees.json", "trees_index.json", "trees_embeddings.npy")
  44. DRIFT_NOTES = (
  45. {
  46. "key": "dim_attributes_schema_drift",
  47. "formal": DIM_ATTRIBUTE_BY_TYPE,
  48. "note": "phase3/taxonomy use how工序/what构成/why原理; legacy schemas still contain how/what for two payload types.",
  49. },
  50. {
  51. "key": "content_shape_drift",
  52. "note": "formal ingest boundary uses string content; what/why objects are serialized before payload draft.",
  53. },
  54. {
  55. "key": "how_step_wording_drift",
  56. "note": "formal code uses input/directive/output even when older phase text mentions 目的/指引/产出.",
  57. },
  58. )
  59. @dataclass(frozen=True)
  60. class ContractArtifact:
  61. name: str
  62. contract_type: str
  63. source_path: Path
  64. content: str
  65. content_hash: str
  66. def snapshot(self) -> dict[str, Any]:
  67. return {
  68. "name": self.name,
  69. "contract_type": self.contract_type,
  70. "relative_path": str(self.source_path.relative_to(ROOT)),
  71. }
  72. @dataclass(frozen=True)
  73. class SkillContract:
  74. """Immutable view of the extraction skill docs used by one decode run."""
  75. skill_dir: Path
  76. artifacts: tuple[ContractArtifact, ...]
  77. scope_tree_cache: tuple[dict[str, Any], ...] = ()
  78. @property
  79. def content_hash(self) -> str:
  80. h = hashlib.sha256()
  81. for artifact in self.artifacts:
  82. h.update(artifact.name.encode("utf-8"))
  83. h.update(artifact.content_hash.encode("ascii"))
  84. for item in self.scope_tree_cache:
  85. h.update(str(item.get("path", "")).encode("utf-8"))
  86. h.update(str(item.get("content_hash", "")).encode("ascii"))
  87. return h.hexdigest()
  88. def get(self, name: str) -> ContractArtifact:
  89. for artifact in self.artifacts:
  90. if artifact.name == name:
  91. return artifact
  92. raise KeyError(name)
  93. @property
  94. def phase1(self) -> str:
  95. return self.get("extraction/phase1-frame.md").content
  96. @property
  97. def phase2(self) -> str:
  98. return self.get("extraction/phase2-scope.md").content
  99. @property
  100. def phase3(self) -> str:
  101. return self.get("extraction/phase3-assemble.md").content
  102. def schema_for_particle_type(self, particle_type: str) -> dict[str, Any]:
  103. artifact = self.get(f"format/ingest-payload-{particle_type}.schema.json")
  104. return json.loads(artifact.content)
  105. def prompt_sources(self) -> list[dict[str, Any]]:
  106. return [a.snapshot() | {"content_hash": a.content_hash} for a in self.artifacts if a.contract_type == "prompt"]
  107. def scope_tree_sources(self) -> list[dict[str, Any]]:
  108. return list(self.scope_tree_cache)
  109. def constants(self) -> dict[str, Any]:
  110. return {
  111. "knowledge_types": list(KNOWLEDGE_TYPES),
  112. "business_stages": list(BUSINESS_STAGES),
  113. "creation_stages": list(CREATION_STAGES),
  114. "what_kinds": list(WHAT_KINDS),
  115. "scope_types": list(SCOPE_TYPES),
  116. "scope_type_cn": SCOPE_TYPE_CN,
  117. "dim_creations": list(DIM_CREATIONS),
  118. "dim_attribute_by_type": DIM_ATTRIBUTE_BY_TYPE,
  119. "custom_ext_keys_by_type": {k: list(v) for k, v in CUSTOM_EXT_KEYS_BY_TYPE.items()},
  120. }
  121. def snapshots(self, *, version_label: str | None = None) -> list[ContractSnapshot]:
  122. snapshots = [
  123. ContractSnapshot(
  124. contract_name="创作知识提取-skill",
  125. contract_type="skill",
  126. version_label=version_label,
  127. content_hash=self.content_hash,
  128. source_path=str(self.skill_dir.relative_to(ROOT)),
  129. snapshot={
  130. "artifact_count": len(self.artifacts),
  131. "artifacts": [a.snapshot() for a in self.artifacts],
  132. "scope_tree_cache": self.scope_tree_sources(),
  133. "constants": self.constants(),
  134. "drift_notes": list(DRIFT_NOTES),
  135. },
  136. )
  137. ]
  138. snapshots.extend(
  139. ContractSnapshot(
  140. contract_name=artifact.name,
  141. contract_type=artifact.contract_type,
  142. version_label=version_label,
  143. content_hash=artifact.content_hash,
  144. source_path=str(artifact.source_path.relative_to(ROOT)),
  145. snapshot=artifact.snapshot(),
  146. )
  147. for artifact in self.artifacts
  148. )
  149. return snapshots
  150. def _hash_text(text: str) -> str:
  151. return hashlib.sha256(text.encode("utf-8")).hexdigest()
  152. def _hash_bytes(data: bytes) -> str:
  153. return hashlib.sha256(data).hexdigest()
  154. def _artifact_type(path: Path) -> str:
  155. parts = path.parts
  156. if "extraction" in parts:
  157. return "prompt_phase"
  158. if "format" in parts:
  159. return "schema"
  160. if "taxonomy" in parts:
  161. return "taxonomy"
  162. if path.name == "README.md":
  163. return "readme"
  164. if "tools" in parts:
  165. return "tool_contract"
  166. return "contract"
  167. def _scope_tree_cache(scope_tree_dir: Path = SCOPE_TREE_DIR) -> tuple[dict[str, Any], ...]:
  168. out: list[dict[str, Any]] = []
  169. for name in SCOPE_TREE_FILES:
  170. path = scope_tree_dir / name
  171. if not path.exists():
  172. continue
  173. stat = path.stat()
  174. out.append(
  175. {
  176. "path": str(path.relative_to(ROOT)),
  177. "size": stat.st_size,
  178. "mtime": int(stat.st_mtime),
  179. "content_hash": _hash_bytes(path.read_bytes()),
  180. }
  181. )
  182. return tuple(out)
  183. def _iter_contract_files(skill_dir: Path) -> Iterable[Path]:
  184. preferred = [
  185. skill_dir / "README.md",
  186. skill_dir / "extraction" / "phase1-frame.md",
  187. skill_dir / "extraction" / "phase2-scope.md",
  188. skill_dir / "extraction" / "phase3-assemble.md",
  189. skill_dir / "format" / "ingest-payload-how.schema.json",
  190. skill_dir / "format" / "ingest-payload-what.schema.json",
  191. skill_dir / "format" / "ingest-payload-why.schema.json",
  192. skill_dir / "taxonomy" / "业务阶段.json",
  193. skill_dir / "taxonomy" / "作用域.md",
  194. skill_dir / "taxonomy" / "创作阶段.json",
  195. skill_dir / "taxonomy" / "知识类型.json",
  196. ]
  197. seen: set[Path] = set()
  198. for path in preferred:
  199. if path.exists():
  200. seen.add(path)
  201. yield path
  202. for path in sorted(skill_dir.rglob("*")):
  203. if path.is_file() and path.suffix in {".md", ".json"} and path not in seen:
  204. yield path
  205. def _iter_prompt_files() -> Iterable[Path]:
  206. for name in PROMPT_NAMES:
  207. path = PROMPTS_DIR / f"{name}.txt"
  208. if path.exists():
  209. yield path
  210. def load_contract(skill_dir: Path | str = DEFAULT_SKILL_DIR) -> SkillContract:
  211. root = Path(skill_dir)
  212. if not root.exists():
  213. raise FileNotFoundError(f"skill contract directory not found: {root}")
  214. artifacts: list[ContractArtifact] = []
  215. for path in _iter_contract_files(root):
  216. text = path.read_text(encoding="utf-8")
  217. artifacts.append(
  218. ContractArtifact(
  219. name=str(path.relative_to(root)),
  220. contract_type=_artifact_type(path.relative_to(root)),
  221. source_path=path,
  222. content=text,
  223. content_hash=_hash_text(text),
  224. )
  225. )
  226. for path in _iter_prompt_files():
  227. text = path.read_text(encoding="utf-8")
  228. artifacts.append(
  229. ContractArtifact(
  230. name=f"prompts/{path.name}",
  231. contract_type="prompt",
  232. source_path=path,
  233. content=text,
  234. content_hash=_hash_text(text),
  235. )
  236. )
  237. return SkillContract(skill_dir=root, artifacts=tuple(artifacts), scope_tree_cache=_scope_tree_cache())
  238. def load_decode_contracts(skill_dir: Path | str = DEFAULT_SKILL_DIR) -> SkillContract:
  239. return load_contract(skill_dir)
  240. def compute_contract_hash(contract: SkillContract | None = None) -> str:
  241. return (contract or load_contract()).content_hash
  242. def iter_contract_snapshots(
  243. contract: SkillContract | None = None,
  244. *,
  245. version_label: str | None = None,
  246. ) -> list[ContractSnapshot]:
  247. return (contract or load_contract()).snapshots(version_label=version_label)