Files
novelmaster/noma/scripts/data_modules/state_validator.py
T
2026-06-23 20:29:02 +08:00

250 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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