point_builder.py 8.2 KB

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