payloads.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. """Deterministic payload assembly for decoded creation knowledge."""
  2. from __future__ import annotations
  3. import json
  4. from typing import Any
  5. from decode_content.contracts import DIM_ATTRIBUTE_BY_TYPE
  6. TYPE2ATTR = DIM_ATTRIBUTE_BY_TYPE
  7. CUSTOM_EXT_TYPES = {"str", "num", "date"}
  8. DIM_ATTRIBUTES = {"how", "what", "why"}
  9. class IngestPayloadValidationError(ValueError):
  10. """Raised when a generated payload cannot satisfy the ingest API contract."""
  11. def _text(value: Any) -> str:
  12. return value if isinstance(value, str) else ""
  13. def _post_value(post: Any, key: str, default: Any = "") -> Any:
  14. if isinstance(post, dict):
  15. return post.get(key, default)
  16. return getattr(post, key, default)
  17. def build_content(knowledge: dict[str, Any]) -> str | dict[str, Any]:
  18. t = knowledge.get("type")
  19. if t == "how":
  20. lines = [f"目标:{knowledge.get('purpose', '')}"]
  21. for idx, step in enumerate(knowledge.get("steps", []), 1):
  22. intent = step.get("intent") or step.get("purpose") or ""
  23. label = f"步骤{idx}" + (f"(目的:{intent})" if intent else "")
  24. lines.extend(
  25. [
  26. label,
  27. f" 输入:{step.get('input', '')}",
  28. f" 指引:{step.get('directive', '')}",
  29. f" 产出:{step.get('output', '')}",
  30. ]
  31. )
  32. return "\n".join(lines)
  33. if t == "what":
  34. return {
  35. "name": knowledge.get("title") or "",
  36. "kind": knowledge.get("kind"),
  37. "维度拆分规则": knowledge.get("维度拆分规则") if knowledge.get("kind") == "子集" else None,
  38. "body": [
  39. {
  40. "item_name": item.get("item_name", ""),
  41. "item_desc": item.get("item_desc", ""),
  42. "作用域": item.get("作用域") or [],
  43. }
  44. for item in (knowledge.get("body") or [])
  45. ],
  46. }
  47. return knowledge.get("阐述") or knowledge.get("desc") or knowledge.get("explanation") or ""
  48. def _payload_content(knowledge: dict[str, Any]) -> str:
  49. content = build_content(knowledge)
  50. return content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
  51. def validate_ingest_payload(payload: dict[str, Any]) -> None:
  52. """Validate the minimal external ingest API contract before storing/sending."""
  53. errors: list[str] = []
  54. source = payload.get("source")
  55. if not isinstance(source, dict):
  56. source = {}
  57. errors.append("source 必须是对象")
  58. source_id = _text(source.get("id")).strip()
  59. if not source_id:
  60. errors.append("source.id 不能为空")
  61. elif len(source_id) > 64:
  62. errors.append("source.id 最长 64 字符")
  63. source_type = _text(source.get("source_type"))
  64. if len(source_type) > 32:
  65. errors.append("source.source_type 最长 32 字符")
  66. source_title = _text(source.get("title"))
  67. if len(source_title) > 512:
  68. errors.append("source.title 最长 512 字符")
  69. source_author = _text(source.get("author"))
  70. if len(source_author) > 128:
  71. errors.append("source.author 最长 128 字符")
  72. title = payload.get("title")
  73. if title is not None and len(_text(title)) > 512:
  74. errors.append("title 最长 512 字符")
  75. content = payload.get("content")
  76. if not isinstance(content, str) or not content.strip():
  77. errors.append("content 必须是非空字符串")
  78. dim_attributes = payload.get("dim_attributes")
  79. if not isinstance(dim_attributes, list) or len(dim_attributes) != 1:
  80. errors.append("dim_attributes 必须恰好包含一个值")
  81. elif dim_attributes[0] not in DIM_ATTRIBUTES:
  82. errors.append("dim_attributes 只能是 how/what/why")
  83. for idx, scope in enumerate(payload.get("scopes") or [], 1):
  84. if not isinstance(scope, dict):
  85. errors.append(f"scopes[{idx}] 必须是对象")
  86. continue
  87. value = _text(scope.get("value"))
  88. if len(value) > 128:
  89. errors.append(f"scopes[{idx}].value 最长 128 字符")
  90. for idx, ext in enumerate(payload.get("custom_ext") or [], 1):
  91. if not isinstance(ext, dict):
  92. errors.append(f"custom_ext[{idx}] 必须是对象")
  93. continue
  94. ext_type = ext.get("type")
  95. if ext_type not in CUSTOM_EXT_TYPES:
  96. errors.append(f"custom_ext[{idx}].type 必须是 str/num/date")
  97. if errors:
  98. title_hint = _text(payload.get("title")) or source_id or "unknown"
  99. raise IngestPayloadValidationError(f"{title_hint}: {'; '.join(errors)}")
  100. def _add_scopes(scopes: list[dict[str, str]], seen: set[tuple[str, str]], items: list[dict[str, Any]]) -> None:
  101. for item in items:
  102. scope_type = item.get("scope_type")
  103. value = item.get("value")
  104. if not (scope_type and value):
  105. continue
  106. key = (scope_type, value)
  107. if key in seen:
  108. continue
  109. seen.add(key)
  110. scopes.append({"scope_type": scope_type, "value": value})
  111. def collect_payload_scopes(knowledge: dict[str, Any]) -> list[dict[str, str]]:
  112. scopes: list[dict[str, str]] = []
  113. seen: set[tuple[str, str]] = set()
  114. if knowledge.get("type") == "how":
  115. for step in knowledge.get("steps", []):
  116. _add_scopes(scopes, seen, step.get("作用域", []))
  117. else:
  118. _add_scopes(scopes, seen, knowledge.get("作用域", []))
  119. return scopes
  120. def _source_metadata(post: Any) -> dict[str, Any]:
  121. metadata = {
  122. "platform": _post_value(post, "platform"),
  123. "url": _post_value(post, "url"),
  124. "unique_key": _post_value(post, "unique_key"),
  125. "platform_item_id": _post_value(post, "content_id"),
  126. "content_type": _post_value(post, "content_type"),
  127. "content_mode": _post_value(post, "content_mode"),
  128. "media_count": _post_value(post, "media_count", 0),
  129. "cover_url": _post_value(post, "cover_url"),
  130. }
  131. return {
  132. key: value
  133. for key, value in metadata.items()
  134. if value is not None and value != ""
  135. }
  136. def build_payload(post: Any, knowledge: dict[str, Any], how_titles: dict[str, str] | None = None) -> dict[str, Any]:
  137. how_titles = how_titles or {}
  138. t = knowledge.get("type")
  139. if t not in TYPE2ATTR:
  140. raise IngestPayloadValidationError(f"{knowledge.get('title') or 'unknown'}: unknown knowledge type {t}")
  141. ext = [
  142. {"key": "业务阶段", "type": "str", "value": stage}
  143. for stage in (knowledge.get("业务阶段") or [])
  144. ]
  145. if t == "what" and knowledge.get("概要"):
  146. ext.append({"key": "概要", "type": "str", "value": knowledge["概要"]})
  147. if t == "how":
  148. creation_stages: list[str] = []
  149. seen_stages: set[str] = set()
  150. for step in knowledge.get("steps", []):
  151. stage = step.get("创作阶段")
  152. if stage and stage not in seen_stages:
  153. seen_stages.add(stage)
  154. creation_stages.append(stage)
  155. ext.extend({"key": "创作阶段", "type": "str", "value": stage} for stage in creation_stages)
  156. ext.extend(
  157. {"key": "动作", "type": "str", "value": step["动作"]}
  158. for step in knowledge.get("steps", [])
  159. if step.get("动作")
  160. )
  161. if knowledge.get("role") == "组件" and knowledge.get("parent"):
  162. parent = knowledge["parent"]
  163. ext.append(
  164. {
  165. "key": "出自",
  166. "type": "str",
  167. "value": f"{how_titles.get(parent.get('how_id'), parent.get('how_id'))} 第{parent.get('step')}步",
  168. }
  169. )
  170. payload = {
  171. "source": {
  172. "id": _post_value(post, "id"),
  173. "source_type": "post",
  174. "title": _post_value(post, "title") or "",
  175. "author": _post_value(post, "author_name") or "",
  176. "source_metadata": _source_metadata(post),
  177. },
  178. "title": knowledge.get("title"),
  179. "content": _payload_content(knowledge),
  180. "dim_creations": ["创作"],
  181. "dim_attributes": [TYPE2ATTR[t]],
  182. "scopes": collect_payload_scopes(knowledge),
  183. "custom_ext": ext,
  184. }
  185. validate_ingest_payload(payload)
  186. return payload
  187. def build_payloads(post: Any, knowledges: list[dict[str, Any]]) -> list[dict[str, Any]]:
  188. how_titles = {k.get("id"): k.get("title") for k in knowledges if k.get("type") == "how"}
  189. return [build_payload(post, knowledge, how_titles) for knowledge in knowledges]