point_builder.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. from collections.abc import Iterable, Mapping
  5. from typing import Any
  6. from app.core.knowledge.contracts import (
  7. KnowledgeDependencyDraft,
  8. KnowledgePointDraft,
  9. KnowledgeSnapshot,
  10. )
  11. SECRET_MARKERS = (
  12. "password",
  13. "credential",
  14. "api_key",
  15. "apikey",
  16. "token",
  17. "authorization",
  18. "connection_string",
  19. "secret",
  20. )
  21. VOLATILE_KEYS = {"created_at", "updated_at"}
  22. SUPPORTED_TYPES = {
  23. "BusinessDomain",
  24. "BusinessTerm",
  25. "CodeSet",
  26. "DataFlow",
  27. "DataMeta",
  28. "DataStandard",
  29. "Label",
  30. "DataLabel",
  31. "MetricDefinition",
  32. "Ontology",
  33. }
  34. def _is_secret(key: str) -> bool:
  35. normalized = key.casefold().replace("-", "_")
  36. return any(marker in normalized for marker in SECRET_MARKERS)
  37. def _canonical(value: Any, key: str = "") -> Any:
  38. if _is_secret(key):
  39. return "[redacted]"
  40. if isinstance(value, Mapping):
  41. return {
  42. str(item_key): _canonical(item_value, str(item_key))
  43. for item_key, item_value in sorted(
  44. value.items(), key=lambda item: str(item[0])
  45. )
  46. if str(item_key).casefold() not in VOLATILE_KEYS
  47. }
  48. if isinstance(value, (list, tuple, set, frozenset)):
  49. items = [_canonical(item) for item in value]
  50. return sorted(items, key=_canonical_json)
  51. return value
  52. def _canonical_json(value: Any) -> str:
  53. return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
  54. def _hash(value: Any) -> str:
  55. return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest()
  56. def _first(source: Mapping[str, Any], *keys: str) -> Any:
  57. for key in keys:
  58. value = source.get(key)
  59. if value is not None and value != "":
  60. return value
  61. return None
  62. def _scope(source: Mapping[str, Any]) -> dict[str, Any]:
  63. explicit = source.get("permission_scope")
  64. if isinstance(explicit, Mapping):
  65. return dict(_canonical(explicit))
  66. domain_uid = source.get("business_domain_uid")
  67. if domain_uid:
  68. return {"business_domains": [str(domain_uid)]}
  69. return {"business_domains": []}
  70. def _content(value: Any) -> str:
  71. safe = _canonical(value)
  72. return safe if isinstance(safe, str) else _canonical_json(safe)
  73. def _build_point(
  74. *,
  75. prefix: str,
  76. semantic_path: str,
  77. value: Any,
  78. permission_scope: Mapping[str, Any],
  79. metadata: Mapping[str, Any] | None = None,
  80. ) -> KnowledgePointDraft:
  81. content = _content(value)
  82. safe_metadata = dict(_canonical(metadata or {}))
  83. safe_scope = dict(_canonical(permission_scope))
  84. return KnowledgePointDraft(
  85. point_key=f"{prefix}/{semantic_path}",
  86. semantic_path=semantic_path,
  87. content=content,
  88. content_hash=_hash(content),
  89. metadata=safe_metadata,
  90. metadata_hash=_hash(safe_metadata),
  91. permission_scope=safe_scope,
  92. permission_hash=_hash(safe_scope),
  93. )
  94. def _require_child_uid(item: Mapping[str, Any], kind: str) -> str:
  95. uid = item.get("uid")
  96. if not uid:
  97. raise ValueError(f"{kind} requires a stable uid")
  98. return str(uid)
  99. def _iter_dicts(value: Any) -> Iterable[Mapping[str, Any]]:
  100. if not value:
  101. return ()
  102. if not isinstance(value, (list, tuple)):
  103. raise ValueError("nested governance values must be a list")
  104. if not all(isinstance(item, Mapping) for item in value):
  105. raise ValueError("nested governance values must contain objects")
  106. return value
  107. def build_knowledge_snapshot(
  108. object_type: str, source: Mapping[str, Any]
  109. ) -> KnowledgeSnapshot:
  110. if object_type not in SUPPORTED_TYPES:
  111. raise ValueError(f"unsupported governance object type: {object_type}")
  112. source_uid = source.get("uid")
  113. if not source_uid:
  114. raise ValueError("governance object uid is required")
  115. source_uid = str(source_uid)
  116. source_revision = int(source.get("version", 1))
  117. permission_scope = _scope(source)
  118. prefix = f"{object_type}/{source_uid}"
  119. points: list[KnowledgePointDraft] = []
  120. dependencies: list[KnowledgeDependencyDraft] = []
  121. scalar_specs = (
  122. ("name", ("name_zh", "name", "name_en")),
  123. ("definition", ("definition", "description")),
  124. ("purpose", ("purpose", "script_requirement")),
  125. ("owner", ("owner", "owner_name")),
  126. ("data_type", ("data_type", "type")),
  127. ("formula", ("formula",)),
  128. ("unit", ("unit",)),
  129. )
  130. for semantic_path, keys in scalar_specs:
  131. value = _first(source, *keys)
  132. if value is not None:
  133. points.append(
  134. _build_point(
  135. prefix=prefix,
  136. semantic_path=semantic_path,
  137. value=value,
  138. permission_scope=permission_scope,
  139. )
  140. )
  141. aliases = source.get("aliases") or []
  142. if not isinstance(aliases, (list, tuple, set, frozenset)):
  143. raise ValueError("aliases must be a list")
  144. for alias in sorted({str(item).strip() for item in aliases if str(item).strip()}):
  145. alias_id = hashlib.sha256(alias.casefold().encode("utf-8")).hexdigest()[:16]
  146. points.append(
  147. _build_point(
  148. prefix=prefix,
  149. semantic_path=f"aliases/{alias_id}",
  150. value=alias,
  151. permission_scope=permission_scope,
  152. )
  153. )
  154. for relation in _iter_dicts(source.get("relations")):
  155. target_uid = relation.get("target_uid")
  156. relation_type = str(relation.get("type") or "").strip().upper()
  157. if not target_uid or not relation_type:
  158. raise ValueError("relationship requires type and stable target uid")
  159. semantic_path = f"{relation_type.casefold()}/{target_uid}"
  160. point = _build_point(
  161. prefix=prefix,
  162. semantic_path=semantic_path,
  163. value=relation,
  164. permission_scope=permission_scope,
  165. metadata={"relation_type": relation_type, "target_uid": str(target_uid)},
  166. )
  167. points.append(point)
  168. target_type = str(relation.get("target_type") or "BusinessDomain")
  169. dependencies.append(
  170. KnowledgeDependencyDraft(
  171. from_point_key=point.point_key,
  172. to_point_key=f"{target_type}/{target_uid}/definition",
  173. relation_type=relation_type.casefold(),
  174. source="governance",
  175. )
  176. )
  177. for collection_name in (
  178. "fields",
  179. "rules",
  180. "values",
  181. "dimensions",
  182. "classes",
  183. "properties",
  184. ):
  185. for item in _iter_dicts(source.get(collection_name)):
  186. item_uid = (
  187. _require_child_uid(item, collection_name[:-1])
  188. if collection_name
  189. in {"fields", "rules", "classes", "properties"}
  190. else str(
  191. item.get("uid")
  192. or item.get("code")
  193. or _require_child_uid(item, collection_name[:-1])
  194. )
  195. )
  196. for semantic_name, keys in (
  197. ("name", ("name_zh", "name", "name_en")),
  198. ("definition", ("definition", "description")),
  199. ("data_type", ("data_type", "type")),
  200. ):
  201. value = _first(item, *keys)
  202. if value is None:
  203. continue
  204. points.append(
  205. _build_point(
  206. prefix=prefix,
  207. semantic_path=f"{collection_name}/{item_uid}/{semantic_name}",
  208. value=value,
  209. permission_scope=permission_scope,
  210. metadata={"child_uid": item_uid, "child_kind": collection_name},
  211. )
  212. )
  213. points.sort(key=lambda point: point.point_key)
  214. if len({point.point_key for point in points}) != len(points):
  215. raise ValueError("knowledge snapshot contains duplicate point keys")
  216. dependencies.sort(
  217. key=lambda item: (
  218. item.from_point_key,
  219. item.to_point_key,
  220. item.relation_type,
  221. item.source,
  222. )
  223. )
  224. safe_source = _canonical(source)
  225. point_set_payload = [
  226. {
  227. "point_key": point.point_key,
  228. "content_hash": point.content_hash,
  229. "metadata_hash": point.metadata_hash,
  230. "permission_hash": point.permission_hash,
  231. }
  232. for point in points
  233. ]
  234. return KnowledgeSnapshot(
  235. source_type=object_type,
  236. source_uid=source_uid,
  237. source_revision=source_revision,
  238. source_snapshot_hash=_hash(safe_source),
  239. point_set_hash=_hash(point_set_payload),
  240. permission_scope=permission_scope,
  241. points=tuple(points),
  242. dependencies=tuple(dependencies),
  243. source_updated_at=source.get("updated_at"),
  244. )