250 lines
7.2 KiB
Python
250 lines
7.2 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Runtime validators/normalizers for state.json sections.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
||
|
||
|
||
FORESHADOWING_STATUS_PENDING = "未回收"
|
||
FORESHADOWING_STATUS_RESOLVED = "已回收"
|
||
|
||
FORESHADOWING_TIER_CORE = "核心"
|
||
FORESHADOWING_TIER_SUB = "支线"
|
||
FORESHADOWING_TIER_DECOR = "装饰"
|
||
|
||
FORESHADOWING_PLANTED_KEYS = [
|
||
"planted_chapter",
|
||
"added_chapter",
|
||
"source_chapter",
|
||
"start_chapter",
|
||
"chapter",
|
||
]
|
||
|
||
FORESHADOWING_TARGET_KEYS = [
|
||
"target_chapter",
|
||
"due_chapter",
|
||
"deadline_chapter",
|
||
"resolve_by_chapter",
|
||
"target",
|
||
]
|
||
|
||
_PENDING_STATUS_TEXT = {"未回收", "待回收", "进行中", "未解决", "pending", "active"}
|
||
_RESOLVED_STATUS_TEXT = {"已回收", "已完成", "已解决", "完成", "resolved", "done", "complete"}
|
||
|
||
_TIER_CORE_TEXT = {"核心", "主线", "core", "main"}
|
||
_TIER_DECOR_TEXT = {"装饰", "次要", "decor", "decoration"}
|
||
|
||
_PATTERN_FIELDS = [
|
||
"coolpoint_patterns",
|
||
"coolpoint_pattern",
|
||
"cool_point_patterns",
|
||
"cool_point_pattern",
|
||
"patterns",
|
||
"pattern",
|
||
]
|
||
|
||
_PATTERN_SPLIT_RE = re.compile(r"[、,,/|+;;。]+")
|
||
|
||
|
||
def to_positive_int(value: Any) -> Optional[int]:
|
||
if value is None or isinstance(value, bool):
|
||
return None
|
||
|
||
try:
|
||
number = int(value)
|
||
return number if number > 0 else None
|
||
except (TypeError, ValueError):
|
||
if isinstance(value, str):
|
||
matched = re.search(r"\d+", value)
|
||
if matched:
|
||
number = int(matched.group(0))
|
||
return number if number > 0 else None
|
||
return None
|
||
|
||
|
||
def resolve_chapter_field(item: Mapping[str, Any], keys: Sequence[str]) -> Optional[int]:
|
||
for key in keys:
|
||
if key in item:
|
||
chapter = to_positive_int(item.get(key))
|
||
if chapter is not None:
|
||
return chapter
|
||
return None
|
||
|
||
|
||
def normalize_foreshadowing_status(
|
||
raw_status: Any,
|
||
default: str = FORESHADOWING_STATUS_PENDING,
|
||
) -> str:
|
||
text = str(raw_status or "").strip()
|
||
if not text:
|
||
return default
|
||
|
||
text_lower = text.lower()
|
||
if (
|
||
text in _RESOLVED_STATUS_TEXT
|
||
or text_lower in _RESOLVED_STATUS_TEXT
|
||
or FORESHADOWING_STATUS_RESOLVED in text
|
||
):
|
||
return FORESHADOWING_STATUS_RESOLVED
|
||
|
||
if text in _PENDING_STATUS_TEXT or text_lower in _PENDING_STATUS_TEXT:
|
||
return FORESHADOWING_STATUS_PENDING
|
||
|
||
return default
|
||
|
||
|
||
def is_resolved_foreshadowing_status(raw_status: Any) -> bool:
|
||
return normalize_foreshadowing_status(raw_status) == FORESHADOWING_STATUS_RESOLVED
|
||
|
||
|
||
def normalize_foreshadowing_tier(
|
||
raw_tier: Any,
|
||
default: str = FORESHADOWING_TIER_SUB,
|
||
) -> str:
|
||
text = str(raw_tier or "").strip()
|
||
if not text:
|
||
return default
|
||
|
||
text_lower = text.lower()
|
||
if text in _TIER_CORE_TEXT or text_lower in _TIER_CORE_TEXT:
|
||
return FORESHADOWING_TIER_CORE
|
||
if text in _TIER_DECOR_TEXT or text_lower in _TIER_DECOR_TEXT:
|
||
return FORESHADOWING_TIER_DECOR
|
||
return default
|
||
|
||
|
||
def split_patterns(raw_value: Any) -> List[str]:
|
||
if raw_value is None:
|
||
return []
|
||
|
||
tokens: List[str] = []
|
||
if isinstance(raw_value, list):
|
||
for item in raw_value:
|
||
text = str(item).strip()
|
||
if text:
|
||
tokens.append(text)
|
||
elif isinstance(raw_value, str):
|
||
text = raw_value.strip()
|
||
if not text:
|
||
return []
|
||
split_values = [part.strip() for part in _PATTERN_SPLIT_RE.split(text)]
|
||
tokens.extend([part for part in split_values if part])
|
||
else:
|
||
return []
|
||
|
||
deduped: List[str] = []
|
||
seen = set()
|
||
for token in tokens:
|
||
if token not in seen:
|
||
seen.add(token)
|
||
deduped.append(token)
|
||
return deduped
|
||
|
||
|
||
def count_patterns(raw_value: Any) -> Optional[int]:
|
||
patterns = split_patterns(raw_value)
|
||
if not patterns:
|
||
return None
|
||
return len(patterns)
|
||
|
||
|
||
def normalize_foreshadowing_item(item: Mapping[str, Any]) -> Dict[str, Any]:
|
||
normalized = dict(item)
|
||
|
||
normalized["status"] = normalize_foreshadowing_status(item.get("status"))
|
||
normalized["tier"] = normalize_foreshadowing_tier(item.get("tier"))
|
||
|
||
content = str(item.get("content") or "").strip()
|
||
if content:
|
||
normalized["content"] = content
|
||
|
||
planted_chapter = resolve_chapter_field(item, FORESHADOWING_PLANTED_KEYS)
|
||
if planted_chapter is not None:
|
||
normalized["planted_chapter"] = planted_chapter
|
||
|
||
target_chapter = resolve_chapter_field(item, FORESHADOWING_TARGET_KEYS)
|
||
if target_chapter is not None:
|
||
normalized["target_chapter"] = target_chapter
|
||
|
||
resolved_chapter = resolve_chapter_field(item, ["resolved_chapter", "resolved_at_chapter", "resolved"])
|
||
if resolved_chapter is not None:
|
||
normalized["resolved_chapter"] = resolved_chapter
|
||
|
||
return normalized
|
||
|
||
|
||
def normalize_foreshadowing_list(raw_items: Any) -> List[Dict[str, Any]]:
|
||
if not isinstance(raw_items, list):
|
||
return []
|
||
|
||
normalized: List[Dict[str, Any]] = []
|
||
for raw_item in raw_items:
|
||
if isinstance(raw_item, Mapping):
|
||
normalized.append(normalize_foreshadowing_item(raw_item))
|
||
return normalized
|
||
|
||
|
||
def normalize_chapter_meta_entry(entry: Mapping[str, Any]) -> Dict[str, Any]:
|
||
normalized = dict(entry)
|
||
|
||
merged_patterns: List[str] = []
|
||
seen = set()
|
||
for field_name in _PATTERN_FIELDS:
|
||
for pattern in split_patterns(entry.get(field_name)):
|
||
if pattern not in seen:
|
||
seen.add(pattern)
|
||
merged_patterns.append(pattern)
|
||
|
||
if merged_patterns:
|
||
normalized["coolpoint_patterns"] = merged_patterns
|
||
|
||
return normalized
|
||
|
||
|
||
def normalize_chapter_meta(raw_chapter_meta: Any) -> Dict[str, Dict[str, Any]]:
|
||
if not isinstance(raw_chapter_meta, Mapping):
|
||
return {}
|
||
|
||
normalized: Dict[str, Dict[str, Any]] = {}
|
||
for chapter_key, chapter_entry in raw_chapter_meta.items():
|
||
if isinstance(chapter_entry, Mapping):
|
||
normalized[str(chapter_key)] = normalize_chapter_meta_entry(chapter_entry)
|
||
return normalized
|
||
|
||
|
||
def get_chapter_meta_entry(state: Mapping[str, Any], chapter: int) -> Dict[str, Any]:
|
||
chapter_meta = state.get("chapter_meta", {})
|
||
if not isinstance(chapter_meta, Mapping):
|
||
return {}
|
||
|
||
for lookup_key in (f"{chapter:04d}", str(chapter)):
|
||
value = chapter_meta.get(lookup_key)
|
||
if isinstance(value, Mapping):
|
||
return normalize_chapter_meta_entry(value)
|
||
|
||
for raw_key, raw_value in chapter_meta.items():
|
||
if to_positive_int(raw_key) == chapter and isinstance(raw_value, Mapping):
|
||
return normalize_chapter_meta_entry(raw_value)
|
||
|
||
return {}
|
||
|
||
|
||
def normalize_state_runtime_sections(state: Dict[str, Any]) -> Dict[str, Any]:
|
||
if not isinstance(state, dict):
|
||
return {}
|
||
|
||
plot_threads = state.get("plot_threads")
|
||
if not isinstance(plot_threads, dict):
|
||
plot_threads = {}
|
||
state["plot_threads"] = plot_threads
|
||
plot_threads["foreshadowing"] = normalize_foreshadowing_list(plot_threads.get("foreshadowing"))
|
||
|
||
state["chapter_meta"] = normalize_chapter_meta(state.get("chapter_meta", {}))
|
||
return state
|
||
|