feat: initial commit

This commit is contained in:
2026-06-23 20:29:02 +08:00
commit ea8f6066c4
217 changed files with 60754 additions and 0 deletions
@@ -0,0 +1,249 @@
#!/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