contracts.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  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": "content_shape_drift",
  47. "note": "formal ingest boundary uses string content; what/why objects are serialized before payload draft.",
  48. },
  49. {
  50. "key": "how_step_wording_drift",
  51. "note": "formal code uses input/directive/output even when older phase text mentions 目的/指引/产出.",
  52. },
  53. )
  54. @dataclass(frozen=True)
  55. class ContractArtifact:
  56. name: str
  57. contract_type: str
  58. source_path: Path
  59. content: str
  60. content_hash: str
  61. def snapshot(self) -> dict[str, Any]:
  62. return {
  63. "name": self.name,
  64. "contract_type": self.contract_type,
  65. "relative_path": str(self.source_path.relative_to(ROOT)),
  66. }
  67. @dataclass(frozen=True)
  68. class SkillContract:
  69. """Immutable view of the extraction skill docs used by one decode run."""
  70. skill_dir: Path
  71. artifacts: tuple[ContractArtifact, ...]
  72. scope_tree_cache: tuple[dict[str, Any], ...] = ()
  73. @property
  74. def content_hash(self) -> str:
  75. h = hashlib.sha256()
  76. for artifact in self.artifacts:
  77. h.update(artifact.name.encode("utf-8"))
  78. h.update(artifact.content_hash.encode("ascii"))
  79. for item in self.scope_tree_cache:
  80. h.update(str(item.get("path", "")).encode("utf-8"))
  81. h.update(str(item.get("content_hash", "")).encode("ascii"))
  82. return h.hexdigest()
  83. def get(self, name: str) -> ContractArtifact:
  84. for artifact in self.artifacts:
  85. if artifact.name == name:
  86. return artifact
  87. raise KeyError(name)
  88. @property
  89. def phase1(self) -> str:
  90. return self.get("extraction/phase1-frame.md").content
  91. @property
  92. def phase2(self) -> str:
  93. return self.get("extraction/phase2-scope.md").content
  94. @property
  95. def phase3(self) -> str:
  96. return self.get("extraction/phase3-assemble.md").content
  97. def schema_for_particle_type(self, particle_type: str) -> dict[str, Any]:
  98. artifact = self.get(f"format/ingest-payload-{particle_type}.schema.json")
  99. return json.loads(artifact.content)
  100. def prompt_sources(self) -> list[dict[str, Any]]:
  101. return [a.snapshot() | {"content_hash": a.content_hash} for a in self.artifacts if a.contract_type == "prompt"]
  102. def scope_tree_sources(self) -> list[dict[str, Any]]:
  103. return list(self.scope_tree_cache)
  104. def constants(self) -> dict[str, Any]:
  105. return {
  106. "knowledge_types": list(KNOWLEDGE_TYPES),
  107. "business_stages": list(BUSINESS_STAGES),
  108. "creation_stages": list(CREATION_STAGES),
  109. "what_kinds": list(WHAT_KINDS),
  110. "scope_types": list(SCOPE_TYPES),
  111. "scope_type_cn": SCOPE_TYPE_CN,
  112. "dim_creations": list(DIM_CREATIONS),
  113. "dim_attribute_by_type": DIM_ATTRIBUTE_BY_TYPE,
  114. "custom_ext_keys_by_type": {k: list(v) for k, v in CUSTOM_EXT_KEYS_BY_TYPE.items()},
  115. }
  116. def snapshots(self, *, version_label: str | None = None) -> list[ContractSnapshot]:
  117. snapshots = [
  118. ContractSnapshot(
  119. contract_name="创作知识提取-skill",
  120. contract_type="skill",
  121. version_label=version_label,
  122. content_hash=self.content_hash,
  123. source_path=str(self.skill_dir.relative_to(ROOT)),
  124. snapshot={
  125. "artifact_count": len(self.artifacts),
  126. "artifacts": [a.snapshot() for a in self.artifacts],
  127. "scope_tree_cache": self.scope_tree_sources(),
  128. "constants": self.constants(),
  129. "drift_notes": list(DRIFT_NOTES),
  130. },
  131. )
  132. ]
  133. snapshots.extend(
  134. ContractSnapshot(
  135. contract_name=artifact.name,
  136. contract_type=artifact.contract_type,
  137. version_label=version_label,
  138. content_hash=artifact.content_hash,
  139. source_path=str(artifact.source_path.relative_to(ROOT)),
  140. snapshot=artifact.snapshot(),
  141. )
  142. for artifact in self.artifacts
  143. )
  144. return snapshots
  145. def _hash_text(text: str) -> str:
  146. return hashlib.sha256(text.encode("utf-8")).hexdigest()
  147. def _hash_bytes(data: bytes) -> str:
  148. return hashlib.sha256(data).hexdigest()
  149. def _artifact_type(path: Path) -> str:
  150. parts = path.parts
  151. if "extraction" in parts:
  152. return "prompt_phase"
  153. if "format" in parts:
  154. return "schema"
  155. if "taxonomy" in parts:
  156. return "taxonomy"
  157. if path.name == "README.md":
  158. return "readme"
  159. if "tools" in parts:
  160. return "tool_contract"
  161. return "contract"
  162. def _scope_tree_cache(scope_tree_dir: Path = SCOPE_TREE_DIR) -> tuple[dict[str, Any], ...]:
  163. out: list[dict[str, Any]] = []
  164. for name in SCOPE_TREE_FILES:
  165. path = scope_tree_dir / name
  166. if not path.exists():
  167. continue
  168. stat = path.stat()
  169. out.append(
  170. {
  171. "path": str(path.relative_to(ROOT)),
  172. "size": stat.st_size,
  173. "mtime": int(stat.st_mtime),
  174. "content_hash": _hash_bytes(path.read_bytes()),
  175. }
  176. )
  177. return tuple(out)
  178. def _iter_contract_files(skill_dir: Path) -> Iterable[Path]:
  179. preferred = [
  180. skill_dir / "README.md",
  181. skill_dir / "extraction" / "phase1-frame.md",
  182. skill_dir / "extraction" / "phase2-scope.md",
  183. skill_dir / "extraction" / "phase3-assemble.md",
  184. skill_dir / "format" / "ingest-payload-how.schema.json",
  185. skill_dir / "format" / "ingest-payload-what.schema.json",
  186. skill_dir / "format" / "ingest-payload-why.schema.json",
  187. skill_dir / "taxonomy" / "业务阶段.json",
  188. skill_dir / "taxonomy" / "作用域.md",
  189. skill_dir / "taxonomy" / "创作阶段.json",
  190. skill_dir / "taxonomy" / "知识类型.json",
  191. ]
  192. seen: set[Path] = set()
  193. for path in preferred:
  194. if path.exists():
  195. seen.add(path)
  196. yield path
  197. for path in sorted(skill_dir.rglob("*")):
  198. if path.is_file() and path.suffix in {".md", ".json"} and path not in seen:
  199. yield path
  200. def _iter_prompt_files() -> Iterable[Path]:
  201. for name in PROMPT_NAMES:
  202. path = PROMPTS_DIR / f"{name}.txt"
  203. if path.exists():
  204. yield path
  205. def load_contract(skill_dir: Path | str = DEFAULT_SKILL_DIR) -> SkillContract:
  206. root = Path(skill_dir)
  207. if not root.exists():
  208. raise FileNotFoundError(f"skill contract directory not found: {root}")
  209. artifacts: list[ContractArtifact] = []
  210. for path in _iter_contract_files(root):
  211. text = path.read_text(encoding="utf-8")
  212. artifacts.append(
  213. ContractArtifact(
  214. name=str(path.relative_to(root)),
  215. contract_type=_artifact_type(path.relative_to(root)),
  216. source_path=path,
  217. content=text,
  218. content_hash=_hash_text(text),
  219. )
  220. )
  221. for path in _iter_prompt_files():
  222. text = path.read_text(encoding="utf-8")
  223. artifacts.append(
  224. ContractArtifact(
  225. name=f"prompts/{path.name}",
  226. contract_type="prompt",
  227. source_path=path,
  228. content=text,
  229. content_hash=_hash_text(text),
  230. )
  231. )
  232. return SkillContract(skill_dir=root, artifacts=tuple(artifacts), scope_tree_cache=_scope_tree_cache())
  233. def load_decode_contracts(skill_dir: Path | str = DEFAULT_SKILL_DIR) -> SkillContract:
  234. return load_contract(skill_dir)
  235. def compute_contract_hash(contract: SkillContract | None = None) -> str:
  236. return (contract or load_contract()).content_hash
  237. def iter_contract_artifacts(
  238. contract: SkillContract | None = None,
  239. *,
  240. version_label: str | None = None,
  241. ) -> list[ContractSnapshot]:
  242. return (contract or load_contract()).snapshots(version_label=version_label)
  243. def iter_contract_snapshots(
  244. contract: SkillContract | None = None,
  245. *,
  246. version_label: str | None = None,
  247. ) -> list[ContractSnapshot]:
  248. """Backward-compatible alias for tests and callers that need artifact payloads."""
  249. return iter_contract_artifacts(contract, version_label=version_label)