|
| 1 | +"""删章(并重排章节号)后的扩展编排:伏笔 JSON、快照内嵌 JSON、向量元数据等。 |
| 2 | +
|
| 3 | +新增一种侧车数据时:实现 ``ChapterRenumberExtension`` 并在依赖注入处注册即可, |
| 4 | +避免在 ``SqliteChapterRepository`` 内无限堆叠硬编码表名。 |
| 5 | +""" |
| 6 | +from __future__ import annotations |
| 7 | + |
| 8 | +import json |
| 9 | +import logging |
| 10 | +from dataclasses import dataclass, field |
| 11 | +from typing import Any, Callable, FrozenSet, List, Optional, Protocol, Sequence |
| 12 | + |
| 13 | +from domain.novel.chapter_renumber.json_walk import ( |
| 14 | + DEFAULT_CHAPTER_INTEGER_JSON_KEYS, |
| 15 | + renumber_chapter_integers_in_json, |
| 16 | +) |
| 17 | +from domain.novel.value_objects.chapter_renumber_spec import ChapterRenumberSpec |
| 18 | +from domain.novel.value_objects.novel_id import NovelId |
| 19 | +from infrastructure.persistence.database.connection import DatabaseConnection |
| 20 | + |
| 21 | +logger = logging.getLogger(__name__) |
| 22 | + |
| 23 | +VectorCollectionResolver = Callable[[str], List[str]] |
| 24 | + |
| 25 | + |
| 26 | +def default_vector_collection_names(novel_id: str) -> List[str]: |
| 27 | + """默认与各 IndexingService 集合命名一致;迭代时可在 Context 中替换整个 resolver。""" |
| 28 | + return [ |
| 29 | + f"novel_{novel_id}_chunks", |
| 30 | + f"novel_{novel_id}_triples", |
| 31 | + "chapters", |
| 32 | + ] |
| 33 | + |
| 34 | + |
| 35 | +@dataclass |
| 36 | +class ChapterRenumberContext: |
| 37 | + """扩展点共享依赖(由 DI 组装,便于单测替换)。""" |
| 38 | + |
| 39 | + db: DatabaseConnection |
| 40 | + foreshadowing_repository: Optional[Any] = None |
| 41 | + vector_store: Optional[Any] = None |
| 42 | + vector_collection_names_resolver: VectorCollectionResolver = default_vector_collection_names |
| 43 | + snapshot_json_keys: FrozenSet[str] = field( |
| 44 | + default_factory=lambda: DEFAULT_CHAPTER_INTEGER_JSON_KEYS |
| 45 | + ) |
| 46 | + |
| 47 | + |
| 48 | +class ChapterRenumberExtension(Protocol): |
| 49 | + """单种「章号侧车数据」的重排逻辑。""" |
| 50 | + |
| 51 | + @property |
| 52 | + def name(self) -> str: ... |
| 53 | + |
| 54 | + def apply(self, spec: ChapterRenumberSpec, ctx: ChapterRenumberContext) -> None: ... |
| 55 | + |
| 56 | + |
| 57 | +class ForeshadowRegistryChapterRenumberExtension: |
| 58 | + name = "foreshadow_registry" |
| 59 | + |
| 60 | + def apply(self, spec: ChapterRenumberSpec, ctx: ChapterRenumberContext) -> None: |
| 61 | + repo = ctx.foreshadowing_repository |
| 62 | + if repo is None: |
| 63 | + return |
| 64 | + reg = repo.get_by_novel_id(NovelId(spec.novel_id)) |
| 65 | + if reg is None: |
| 66 | + return |
| 67 | + reg.apply_chapter_renumber_after_chapter_deleted(spec) |
| 68 | + repo.save(reg) |
| 69 | + |
| 70 | + |
| 71 | +class VectorStoreChapterRenumberExtension: |
| 72 | + name = "vector_store" |
| 73 | + |
| 74 | + def apply(self, spec: ChapterRenumberSpec, ctx: ChapterRenumberContext) -> None: |
| 75 | + vs = ctx.vector_store |
| 76 | + if vs is None: |
| 77 | + return |
| 78 | + renumber = getattr(vs, "renumber_chapter_metadata_for_novel", None) |
| 79 | + if not callable(renumber): |
| 80 | + return |
| 81 | + names = ctx.vector_collection_names_resolver(spec.novel_id) |
| 82 | + try: |
| 83 | + n = int(renumber(spec, names)) |
| 84 | + logger.info( |
| 85 | + "向量章号元数据已处理 %s 条(含 id 重挂) novel=%s", |
| 86 | + n, |
| 87 | + spec.novel_id, |
| 88 | + ) |
| 89 | + except Exception: |
| 90 | + logger.exception("向量章号元数据更新失败 novel=%s", spec.novel_id) |
| 91 | + |
| 92 | + |
| 93 | +class SnapshotJsonChapterRenumberExtension: |
| 94 | + """修正快照中 graph_state / foreshadow_state 内嵌 JSON 的章号整型字段。 |
| 95 | +
|
| 96 | + 说明:chapter_pointers 存章节主键 id,不随章号重排变化,故不修改。 |
| 97 | + """ |
| 98 | + |
| 99 | + name = "novel_snapshots_json" |
| 100 | + |
| 101 | + def apply(self, spec: ChapterRenumberSpec, ctx: ChapterRenumberContext) -> None: |
| 102 | + rows = ctx.db.fetch_all( |
| 103 | + """ |
| 104 | + SELECT id, graph_state, foreshadow_state |
| 105 | + FROM novel_snapshots |
| 106 | + WHERE novel_id = ? |
| 107 | + """, |
| 108 | + (spec.novel_id,), |
| 109 | + ) |
| 110 | + keys = ctx.snapshot_json_keys |
| 111 | + for row in rows: |
| 112 | + sid = row["id"] |
| 113 | + set_parts: List[str] = [] |
| 114 | + params: List[Any] = [] |
| 115 | + for col in ("graph_state", "foreshadow_state"): |
| 116 | + raw = row.get(col) |
| 117 | + if not raw or not str(raw).strip(): |
| 118 | + continue |
| 119 | + try: |
| 120 | + data = json.loads(raw) |
| 121 | + except json.JSONDecodeError: |
| 122 | + logger.warning("快照 %s 列 %s 非 JSON,跳过章号修正", sid, col) |
| 123 | + continue |
| 124 | + new_data = renumber_chapter_integers_in_json(data, spec, keys=keys) |
| 125 | + new_raw = json.dumps(new_data, ensure_ascii=False) |
| 126 | + if new_raw == raw: |
| 127 | + continue |
| 128 | + set_parts.append(f"{col} = ?") |
| 129 | + params.append(new_raw) |
| 130 | + if not set_parts: |
| 131 | + continue |
| 132 | + sql = f"UPDATE novel_snapshots SET {', '.join(set_parts)} WHERE id = ?" |
| 133 | + params.append(sid) |
| 134 | + ctx.db.execute(sql, tuple(params)) |
| 135 | + ctx.db.get_connection().commit() |
| 136 | + |
| 137 | + |
| 138 | +class ChapterRenumberCoordinator: |
| 139 | + """按顺序执行已注册的扩展;单个扩展失败不影响其它扩展(记录日志)。""" |
| 140 | + |
| 141 | + def __init__( |
| 142 | + self, |
| 143 | + context: ChapterRenumberContext, |
| 144 | + extensions: Sequence[ChapterRenumberExtension], |
| 145 | + ): |
| 146 | + self._ctx = context |
| 147 | + self._extensions = tuple(extensions) |
| 148 | + |
| 149 | + def on_chapter_deleted(self, novel_id: str, deleted_chapter_number: int) -> None: |
| 150 | + spec = ChapterRenumberSpec( |
| 151 | + novel_id=novel_id, |
| 152 | + deleted_chapter_number=deleted_chapter_number, |
| 153 | + ) |
| 154 | + for ext in self._extensions: |
| 155 | + try: |
| 156 | + ext.apply(spec, self._ctx) |
| 157 | + except Exception: |
| 158 | + logger.exception("章号重排扩展失败: %s", ext.name) |
| 159 | + |
| 160 | + |
| 161 | +def build_default_chapter_renumber_coordinator( |
| 162 | + db: DatabaseConnection, |
| 163 | + *, |
| 164 | + foreshadowing_repository: Optional[Any] = None, |
| 165 | + vector_store: Optional[Any] = None, |
| 166 | + vector_collection_names_resolver: Optional[VectorCollectionResolver] = None, |
| 167 | + snapshot_json_keys: Optional[FrozenSet[str]] = None, |
| 168 | + extra_extensions: Sequence[ChapterRenumberExtension] = (), |
| 169 | +) -> ChapterRenumberCoordinator: |
| 170 | + """工厂:默认扩展集 + 可选附加扩展(插件入口)。""" |
| 171 | + ctx = ChapterRenumberContext( |
| 172 | + db=db, |
| 173 | + foreshadowing_repository=foreshadowing_repository, |
| 174 | + vector_store=vector_store, |
| 175 | + vector_collection_names_resolver=( |
| 176 | + vector_collection_names_resolver or default_vector_collection_names |
| 177 | + ), |
| 178 | + snapshot_json_keys=snapshot_json_keys or DEFAULT_CHAPTER_INTEGER_JSON_KEYS, |
| 179 | + ) |
| 180 | + extensions: List[ChapterRenumberExtension] = [ |
| 181 | + ForeshadowRegistryChapterRenumberExtension(), |
| 182 | + VectorStoreChapterRenumberExtension(), |
| 183 | + SnapshotJsonChapterRenumberExtension(), |
| 184 | + ] |
| 185 | + extensions.extend(extra_extensions) |
| 186 | + return ChapterRenumberCoordinator(ctx, extensions) |
0 commit comments