|
|
@@ -0,0 +1,288 @@
|
|
|
+"""Skill contract loading for the formal decode-content workflow."""
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import hashlib
|
|
|
+import json
|
|
|
+from dataclasses import dataclass
|
|
|
+from pathlib import Path
|
|
|
+from typing import Any, Iterable
|
|
|
+
|
|
|
+from core.prompts import PROMPTS_DIR
|
|
|
+from decode_content.models import ContractSnapshot
|
|
|
+
|
|
|
+
|
|
|
+ROOT = Path(__file__).resolve().parent.parent
|
|
|
+DEFAULT_SKILL_DIR = ROOT / "创作知识提取-skill"
|
|
|
+SCOPE_TREE_DIR = ROOT / "scope_trees"
|
|
|
+
|
|
|
+KNOWLEDGE_TYPES = ("how", "what", "why")
|
|
|
+BUSINESS_STAGES = ("灵感", "选题", "脚本")
|
|
|
+CREATION_STAGES = ("定向", "构思", "结构", "成文", "打磨")
|
|
|
+WHAT_KINDS = ("子集", "多维关系", "序列")
|
|
|
+SCOPE_TYPES = ("substance", "form", "feeling", "effect", "intent")
|
|
|
+REUSE_THRESHOLD = 0.90
|
|
|
+SCOPE_TYPE_CN = {
|
|
|
+ "substance": "实质",
|
|
|
+ "form": "形式",
|
|
|
+ "feeling": "感受",
|
|
|
+ "effect": "作用",
|
|
|
+ "intent": "意图",
|
|
|
+}
|
|
|
+DIM_CREATIONS = ("创作",)
|
|
|
+DIM_ATTRIBUTE_BY_TYPE = {"how": "how工序", "what": "what构成", "why": "why原理"}
|
|
|
+CUSTOM_EXT_KEYS_BY_TYPE = {
|
|
|
+ "how": ("业务阶段", "创作阶段", "动作", "出自"),
|
|
|
+ "what": ("业务阶段", "概要", "出自"),
|
|
|
+ "why": ("业务阶段", "出自"),
|
|
|
+}
|
|
|
+PROMPT_NAMES = (
|
|
|
+ "gate_admit",
|
|
|
+ "gate_refute",
|
|
|
+ "gate_tiebreak",
|
|
|
+ "normalize_scope",
|
|
|
+ "gate_how_admit",
|
|
|
+ "gate_how_refute",
|
|
|
+ "gate_how_tiebreak",
|
|
|
+ "gate_why_refute",
|
|
|
+)
|
|
|
+SCOPE_TREE_FILES = ("trees.json", "trees_index.json", "trees_embeddings.npy")
|
|
|
+DRIFT_NOTES = (
|
|
|
+ {
|
|
|
+ "key": "dim_attributes_schema_drift",
|
|
|
+ "formal": DIM_ATTRIBUTE_BY_TYPE,
|
|
|
+ "note": "phase3/taxonomy use how工序/what构成/why原理; legacy schemas still contain how/what for two payload types.",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "key": "content_shape_drift",
|
|
|
+ "note": "formal ingest boundary uses string content; what/why objects are serialized before payload draft.",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "key": "how_step_wording_drift",
|
|
|
+ "note": "formal code uses input/directive/output even when older phase text mentions 目的/指引/产出.",
|
|
|
+ },
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class ContractArtifact:
|
|
|
+ name: str
|
|
|
+ contract_type: str
|
|
|
+ source_path: Path
|
|
|
+ content: str
|
|
|
+ content_hash: str
|
|
|
+
|
|
|
+ def snapshot(self) -> dict[str, Any]:
|
|
|
+ return {
|
|
|
+ "name": self.name,
|
|
|
+ "contract_type": self.contract_type,
|
|
|
+ "relative_path": str(self.source_path.relative_to(ROOT)),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class SkillContract:
|
|
|
+ """Immutable view of the extraction skill docs used by one decode run."""
|
|
|
+
|
|
|
+ skill_dir: Path
|
|
|
+ artifacts: tuple[ContractArtifact, ...]
|
|
|
+ scope_tree_cache: tuple[dict[str, Any], ...] = ()
|
|
|
+
|
|
|
+ @property
|
|
|
+ def content_hash(self) -> str:
|
|
|
+ h = hashlib.sha256()
|
|
|
+ for artifact in self.artifacts:
|
|
|
+ h.update(artifact.name.encode("utf-8"))
|
|
|
+ h.update(artifact.content_hash.encode("ascii"))
|
|
|
+ for item in self.scope_tree_cache:
|
|
|
+ h.update(str(item.get("path", "")).encode("utf-8"))
|
|
|
+ h.update(str(item.get("content_hash", "")).encode("ascii"))
|
|
|
+ return h.hexdigest()
|
|
|
+
|
|
|
+ def get(self, name: str) -> ContractArtifact:
|
|
|
+ for artifact in self.artifacts:
|
|
|
+ if artifact.name == name:
|
|
|
+ return artifact
|
|
|
+ raise KeyError(name)
|
|
|
+
|
|
|
+ @property
|
|
|
+ def phase1(self) -> str:
|
|
|
+ return self.get("extraction/phase1-frame.md").content
|
|
|
+
|
|
|
+ @property
|
|
|
+ def phase2(self) -> str:
|
|
|
+ return self.get("extraction/phase2-scope.md").content
|
|
|
+
|
|
|
+ @property
|
|
|
+ def phase3(self) -> str:
|
|
|
+ return self.get("extraction/phase3-assemble.md").content
|
|
|
+
|
|
|
+ def schema_for_particle_type(self, particle_type: str) -> dict[str, Any]:
|
|
|
+ artifact = self.get(f"format/ingest-payload-{particle_type}.schema.json")
|
|
|
+ return json.loads(artifact.content)
|
|
|
+
|
|
|
+ def prompt_sources(self) -> list[dict[str, Any]]:
|
|
|
+ return [a.snapshot() | {"content_hash": a.content_hash} for a in self.artifacts if a.contract_type == "prompt"]
|
|
|
+
|
|
|
+ def scope_tree_sources(self) -> list[dict[str, Any]]:
|
|
|
+ return list(self.scope_tree_cache)
|
|
|
+
|
|
|
+ def constants(self) -> dict[str, Any]:
|
|
|
+ return {
|
|
|
+ "knowledge_types": list(KNOWLEDGE_TYPES),
|
|
|
+ "business_stages": list(BUSINESS_STAGES),
|
|
|
+ "creation_stages": list(CREATION_STAGES),
|
|
|
+ "what_kinds": list(WHAT_KINDS),
|
|
|
+ "scope_types": list(SCOPE_TYPES),
|
|
|
+ "scope_type_cn": SCOPE_TYPE_CN,
|
|
|
+ "dim_creations": list(DIM_CREATIONS),
|
|
|
+ "dim_attribute_by_type": DIM_ATTRIBUTE_BY_TYPE,
|
|
|
+ "custom_ext_keys_by_type": {k: list(v) for k, v in CUSTOM_EXT_KEYS_BY_TYPE.items()},
|
|
|
+ }
|
|
|
+
|
|
|
+ def snapshots(self, *, version_label: str | None = None) -> list[ContractSnapshot]:
|
|
|
+ snapshots = [
|
|
|
+ ContractSnapshot(
|
|
|
+ contract_name="创作知识提取-skill",
|
|
|
+ contract_type="skill",
|
|
|
+ version_label=version_label,
|
|
|
+ content_hash=self.content_hash,
|
|
|
+ source_path=str(self.skill_dir.relative_to(ROOT)),
|
|
|
+ snapshot={
|
|
|
+ "artifact_count": len(self.artifacts),
|
|
|
+ "artifacts": [a.snapshot() for a in self.artifacts],
|
|
|
+ "scope_tree_cache": self.scope_tree_sources(),
|
|
|
+ "constants": self.constants(),
|
|
|
+ "drift_notes": list(DRIFT_NOTES),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ ]
|
|
|
+ snapshots.extend(
|
|
|
+ ContractSnapshot(
|
|
|
+ contract_name=artifact.name,
|
|
|
+ contract_type=artifact.contract_type,
|
|
|
+ version_label=version_label,
|
|
|
+ content_hash=artifact.content_hash,
|
|
|
+ source_path=str(artifact.source_path.relative_to(ROOT)),
|
|
|
+ snapshot=artifact.snapshot(),
|
|
|
+ )
|
|
|
+ for artifact in self.artifacts
|
|
|
+ )
|
|
|
+ return snapshots
|
|
|
+
|
|
|
+
|
|
|
+def _hash_text(text: str) -> str:
|
|
|
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
|
+
|
|
|
+
|
|
|
+def _hash_bytes(data: bytes) -> str:
|
|
|
+ return hashlib.sha256(data).hexdigest()
|
|
|
+
|
|
|
+
|
|
|
+def _artifact_type(path: Path) -> str:
|
|
|
+ parts = path.parts
|
|
|
+ if "extraction" in parts:
|
|
|
+ return "prompt_phase"
|
|
|
+ if "format" in parts:
|
|
|
+ return "schema"
|
|
|
+ if "taxonomy" in parts:
|
|
|
+ return "taxonomy"
|
|
|
+ if path.name == "README.md":
|
|
|
+ return "readme"
|
|
|
+ if "tools" in parts:
|
|
|
+ return "tool_contract"
|
|
|
+ return "contract"
|
|
|
+
|
|
|
+
|
|
|
+def _scope_tree_cache(scope_tree_dir: Path = SCOPE_TREE_DIR) -> tuple[dict[str, Any], ...]:
|
|
|
+ out: list[dict[str, Any]] = []
|
|
|
+ for name in SCOPE_TREE_FILES:
|
|
|
+ path = scope_tree_dir / name
|
|
|
+ if not path.exists():
|
|
|
+ continue
|
|
|
+ stat = path.stat()
|
|
|
+ out.append(
|
|
|
+ {
|
|
|
+ "path": str(path.relative_to(ROOT)),
|
|
|
+ "size": stat.st_size,
|
|
|
+ "mtime": int(stat.st_mtime),
|
|
|
+ "content_hash": _hash_bytes(path.read_bytes()),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ return tuple(out)
|
|
|
+
|
|
|
+
|
|
|
+def _iter_contract_files(skill_dir: Path) -> Iterable[Path]:
|
|
|
+ preferred = [
|
|
|
+ skill_dir / "README.md",
|
|
|
+ skill_dir / "extraction" / "phase1-frame.md",
|
|
|
+ skill_dir / "extraction" / "phase2-scope.md",
|
|
|
+ skill_dir / "extraction" / "phase3-assemble.md",
|
|
|
+ skill_dir / "format" / "ingest-payload-how.schema.json",
|
|
|
+ skill_dir / "format" / "ingest-payload-what.schema.json",
|
|
|
+ skill_dir / "format" / "ingest-payload-why.schema.json",
|
|
|
+ skill_dir / "taxonomy" / "业务阶段.json",
|
|
|
+ skill_dir / "taxonomy" / "作用域.md",
|
|
|
+ skill_dir / "taxonomy" / "创作阶段.json",
|
|
|
+ skill_dir / "taxonomy" / "知识类型.json",
|
|
|
+ ]
|
|
|
+ seen: set[Path] = set()
|
|
|
+ for path in preferred:
|
|
|
+ if path.exists():
|
|
|
+ seen.add(path)
|
|
|
+ yield path
|
|
|
+ for path in sorted(skill_dir.rglob("*")):
|
|
|
+ if path.is_file() and path.suffix in {".md", ".json"} and path not in seen:
|
|
|
+ yield path
|
|
|
+
|
|
|
+
|
|
|
+def _iter_prompt_files() -> Iterable[Path]:
|
|
|
+ for name in PROMPT_NAMES:
|
|
|
+ path = PROMPTS_DIR / f"{name}.txt"
|
|
|
+ if path.exists():
|
|
|
+ yield path
|
|
|
+
|
|
|
+
|
|
|
+def load_contract(skill_dir: Path | str = DEFAULT_SKILL_DIR) -> SkillContract:
|
|
|
+ root = Path(skill_dir)
|
|
|
+ if not root.exists():
|
|
|
+ raise FileNotFoundError(f"skill contract directory not found: {root}")
|
|
|
+ artifacts: list[ContractArtifact] = []
|
|
|
+ for path in _iter_contract_files(root):
|
|
|
+ text = path.read_text(encoding="utf-8")
|
|
|
+ artifacts.append(
|
|
|
+ ContractArtifact(
|
|
|
+ name=str(path.relative_to(root)),
|
|
|
+ contract_type=_artifact_type(path.relative_to(root)),
|
|
|
+ source_path=path,
|
|
|
+ content=text,
|
|
|
+ content_hash=_hash_text(text),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ for path in _iter_prompt_files():
|
|
|
+ text = path.read_text(encoding="utf-8")
|
|
|
+ artifacts.append(
|
|
|
+ ContractArtifact(
|
|
|
+ name=f"prompts/{path.name}",
|
|
|
+ contract_type="prompt",
|
|
|
+ source_path=path,
|
|
|
+ content=text,
|
|
|
+ content_hash=_hash_text(text),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ return SkillContract(skill_dir=root, artifacts=tuple(artifacts), scope_tree_cache=_scope_tree_cache())
|
|
|
+
|
|
|
+
|
|
|
+def load_decode_contracts(skill_dir: Path | str = DEFAULT_SKILL_DIR) -> SkillContract:
|
|
|
+ return load_contract(skill_dir)
|
|
|
+
|
|
|
+
|
|
|
+def compute_contract_hash(contract: SkillContract | None = None) -> str:
|
|
|
+ return (contract or load_contract()).content_hash
|
|
|
+
|
|
|
+
|
|
|
+def iter_contract_snapshots(
|
|
|
+ contract: SkillContract | None = None,
|
|
|
+ *,
|
|
|
+ version_label: str | None = None,
|
|
|
+) -> list[ContractSnapshot]:
|
|
|
+ return (contract or load_contract()).snapshots(version_label=version_label)
|