state_validator.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. Runtime validators/normalizers for state.json sections.
  5. """
  6. from __future__ import annotations
  7. import re
  8. from typing import Any, Dict, List, Mapping, Optional, Sequence
  9. FORESHADOWING_STATUS_PENDING = "未回收"
  10. FORESHADOWING_STATUS_RESOLVED = "已回收"
  11. FORESHADOWING_TIER_CORE = "核心"
  12. FORESHADOWING_TIER_SUB = "支线"
  13. FORESHADOWING_TIER_DECOR = "装饰"
  14. FORESHADOWING_PLANTED_KEYS = [
  15. "planted_chapter",
  16. "added_chapter",
  17. "source_chapter",
  18. "start_chapter",
  19. "chapter",
  20. ]
  21. FORESHADOWING_TARGET_KEYS = [
  22. "target_chapter",
  23. "due_chapter",
  24. "deadline_chapter",
  25. "resolve_by_chapter",
  26. "target",
  27. ]
  28. _PENDING_STATUS_TEXT = {"未回收", "待回收", "进行中", "未解决", "pending", "active"}
  29. _RESOLVED_STATUS_TEXT = {"已回收", "已完成", "已解决", "完成", "resolved", "done", "complete"}
  30. _TIER_CORE_TEXT = {"核心", "主线", "core", "main"}
  31. _TIER_DECOR_TEXT = {"装饰", "次要", "decor", "decoration"}
  32. _PATTERN_FIELDS = [
  33. "coolpoint_patterns",
  34. "coolpoint_pattern",
  35. "cool_point_patterns",
  36. "cool_point_pattern",
  37. "patterns",
  38. "pattern",
  39. ]
  40. _PATTERN_SPLIT_RE = re.compile(r"[、,,/|+;;。]+")
  41. def to_positive_int(value: Any) -> Optional[int]:
  42. if value is None or isinstance(value, bool):
  43. return None
  44. try:
  45. number = int(value)
  46. return number if number > 0 else None
  47. except (TypeError, ValueError):
  48. if isinstance(value, str):
  49. matched = re.search(r"\d+", value)
  50. if matched:
  51. number = int(matched.group(0))
  52. return number if number > 0 else None
  53. return None
  54. def resolve_chapter_field(item: Mapping[str, Any], keys: Sequence[str]) -> Optional[int]:
  55. for key in keys:
  56. if key in item:
  57. chapter = to_positive_int(item.get(key))
  58. if chapter is not None:
  59. return chapter
  60. return None
  61. def normalize_foreshadowing_status(
  62. raw_status: Any,
  63. default: str = FORESHADOWING_STATUS_PENDING,
  64. ) -> str:
  65. text = str(raw_status or "").strip()
  66. if not text:
  67. return default
  68. text_lower = text.lower()
  69. if (
  70. text in _RESOLVED_STATUS_TEXT
  71. or text_lower in _RESOLVED_STATUS_TEXT
  72. or FORESHADOWING_STATUS_RESOLVED in text
  73. ):
  74. return FORESHADOWING_STATUS_RESOLVED
  75. if text in _PENDING_STATUS_TEXT or text_lower in _PENDING_STATUS_TEXT:
  76. return FORESHADOWING_STATUS_PENDING
  77. return default
  78. def is_resolved_foreshadowing_status(raw_status: Any) -> bool:
  79. return normalize_foreshadowing_status(raw_status) == FORESHADOWING_STATUS_RESOLVED
  80. def normalize_foreshadowing_tier(
  81. raw_tier: Any,
  82. default: str = FORESHADOWING_TIER_SUB,
  83. ) -> str:
  84. text = str(raw_tier or "").strip()
  85. if not text:
  86. return default
  87. text_lower = text.lower()
  88. if text in _TIER_CORE_TEXT or text_lower in _TIER_CORE_TEXT:
  89. return FORESHADOWING_TIER_CORE
  90. if text in _TIER_DECOR_TEXT or text_lower in _TIER_DECOR_TEXT:
  91. return FORESHADOWING_TIER_DECOR
  92. return default
  93. def split_patterns(raw_value: Any) -> List[str]:
  94. if raw_value is None:
  95. return []
  96. tokens: List[str] = []
  97. if isinstance(raw_value, list):
  98. for item in raw_value:
  99. text = str(item).strip()
  100. if text:
  101. tokens.append(text)
  102. elif isinstance(raw_value, str):
  103. text = raw_value.strip()
  104. if not text:
  105. return []
  106. split_values = [part.strip() for part in _PATTERN_SPLIT_RE.split(text)]
  107. tokens.extend([part for part in split_values if part])
  108. else:
  109. return []
  110. deduped: List[str] = []
  111. seen = set()
  112. for token in tokens:
  113. if token not in seen:
  114. seen.add(token)
  115. deduped.append(token)
  116. return deduped
  117. def count_patterns(raw_value: Any) -> Optional[int]:
  118. patterns = split_patterns(raw_value)
  119. if not patterns:
  120. return None
  121. return len(patterns)
  122. def normalize_foreshadowing_item(item: Mapping[str, Any]) -> Dict[str, Any]:
  123. normalized = dict(item)
  124. normalized["status"] = normalize_foreshadowing_status(item.get("status"))
  125. normalized["tier"] = normalize_foreshadowing_tier(item.get("tier"))
  126. content = str(item.get("content") or "").strip()
  127. if content:
  128. normalized["content"] = content
  129. planted_chapter = resolve_chapter_field(item, FORESHADOWING_PLANTED_KEYS)
  130. if planted_chapter is not None:
  131. normalized["planted_chapter"] = planted_chapter
  132. target_chapter = resolve_chapter_field(item, FORESHADOWING_TARGET_KEYS)
  133. if target_chapter is not None:
  134. normalized["target_chapter"] = target_chapter
  135. resolved_chapter = resolve_chapter_field(item, ["resolved_chapter", "resolved_at_chapter", "resolved"])
  136. if resolved_chapter is not None:
  137. normalized["resolved_chapter"] = resolved_chapter
  138. return normalized
  139. def normalize_foreshadowing_list(raw_items: Any) -> List[Dict[str, Any]]:
  140. if not isinstance(raw_items, list):
  141. return []
  142. normalized: List[Dict[str, Any]] = []
  143. for raw_item in raw_items:
  144. if isinstance(raw_item, Mapping):
  145. normalized.append(normalize_foreshadowing_item(raw_item))
  146. return normalized
  147. def normalize_chapter_meta_entry(entry: Mapping[str, Any]) -> Dict[str, Any]:
  148. normalized = dict(entry)
  149. merged_patterns: List[str] = []
  150. seen = set()
  151. for field_name in _PATTERN_FIELDS:
  152. for pattern in split_patterns(entry.get(field_name)):
  153. if pattern not in seen:
  154. seen.add(pattern)
  155. merged_patterns.append(pattern)
  156. if merged_patterns:
  157. normalized["coolpoint_patterns"] = merged_patterns
  158. return normalized
  159. def normalize_chapter_meta(raw_chapter_meta: Any) -> Dict[str, Dict[str, Any]]:
  160. if not isinstance(raw_chapter_meta, Mapping):
  161. return {}
  162. normalized: Dict[str, Dict[str, Any]] = {}
  163. for chapter_key, chapter_entry in raw_chapter_meta.items():
  164. if isinstance(chapter_entry, Mapping):
  165. normalized[str(chapter_key)] = normalize_chapter_meta_entry(chapter_entry)
  166. return normalized
  167. def get_chapter_meta_entry(state: Mapping[str, Any], chapter: int) -> Dict[str, Any]:
  168. chapter_meta = state.get("chapter_meta", {})
  169. if not isinstance(chapter_meta, Mapping):
  170. return {}
  171. for lookup_key in (f"{chapter:04d}", str(chapter)):
  172. value = chapter_meta.get(lookup_key)
  173. if isinstance(value, Mapping):
  174. return normalize_chapter_meta_entry(value)
  175. for raw_key, raw_value in chapter_meta.items():
  176. if to_positive_int(raw_key) == chapter and isinstance(raw_value, Mapping):
  177. return normalize_chapter_meta_entry(raw_value)
  178. return {}
  179. def normalize_state_runtime_sections(state: Dict[str, Any]) -> Dict[str, Any]:
  180. if not isinstance(state, dict):
  181. return {}
  182. plot_threads = state.get("plot_threads")
  183. if not isinstance(plot_threads, dict):
  184. plot_threads = {}
  185. state["plot_threads"] = plot_threads
  186. plot_threads["foreshadowing"] = normalize_foreshadowing_list(plot_threads.get("foreshadowing"))
  187. state["chapter_meta"] = normalize_chapter_meta(state.get("chapter_meta", {}))
  188. return state