Skip to content

Commit c64a254

Browse files
committed
feat: 删章重排级联、章号侧车编排(伏笔/快照/向量)及接口与前端
Made-with: Cursor
1 parent 60093ba commit c64a254

19 files changed

Lines changed: 1228 additions & 47 deletions

File tree

application/core/services/chapter_service.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""Chapter 应用服务"""
2-
from typing import List, Optional
2+
from typing import List, Optional, Any
33
from datetime import datetime
44
import re
55
from domain.novel.entities.chapter import Chapter, ChapterStatus
@@ -20,18 +20,21 @@ def __init__(
2020
self,
2121
chapter_repository: ChapterRepository,
2222
novel_repository: NovelRepository,
23-
chapter_review_repository=None
23+
chapter_review_repository=None,
24+
chapter_renumber_coordinator: Any = None,
2425
):
2526
"""初始化服务
2627
2728
Args:
2829
chapter_repository: Chapter 仓储
2930
novel_repository: Novel 仓储
3031
chapter_review_repository: Chapter Review 仓储(可选)
32+
chapter_renumber_coordinator: 删章后伏笔/快照/向量等侧车数据章号重排(可选)
3133
"""
3234
self.chapter_repository = chapter_repository
3335
self.novel_repository = novel_repository
3436
self.chapter_review_repository = chapter_review_repository
37+
self._chapter_renumber_coordinator = chapter_renumber_coordinator
3538

3639
def update_chapter_content(
3740
self,
@@ -91,7 +94,19 @@ def delete_chapter(self, chapter_id: str) -> None:
9194
Args:
9295
chapter_id: 章节 ID
9396
"""
97+
chapter = self.chapter_repository.get_by_id(ChapterId(chapter_id))
98+
if chapter is None:
99+
return
100+
novel_id = (
101+
chapter.novel_id.value
102+
if hasattr(chapter.novel_id, "value")
103+
else chapter.novel_id
104+
)
105+
deleted_number = chapter.number
94106
self.chapter_repository.delete(ChapterId(chapter_id))
107+
coordinator = self._chapter_renumber_coordinator
108+
if coordinator is not None:
109+
coordinator.on_chapter_deleted(novel_id, deleted_number)
95110

96111
def get_chapter_by_novel_and_number(
97112
self,
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""删章后章号侧车数据(伏笔、快照 JSON、向量等)重排编排。"""
2+
3+
from application.novel.chapter_renumber.coordinator import (
4+
ChapterRenumberContext,
5+
ChapterRenumberCoordinator,
6+
ChapterRenumberExtension,
7+
build_default_chapter_renumber_coordinator,
8+
default_vector_collection_names,
9+
)
10+
11+
__all__ = [
12+
"ChapterRenumberContext",
13+
"ChapterRenumberCoordinator",
14+
"ChapterRenumberExtension",
15+
"build_default_chapter_renumber_coordinator",
16+
"default_vector_collection_names",
17+
]
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""章号重排:删章后与关系库一致的引用变换(JSON 遍历、伏笔注册表等)。"""
2+
3+
from domain.novel.chapter_renumber.json_walk import (
4+
DEFAULT_CHAPTER_INTEGER_JSON_KEYS,
5+
renumber_chapter_integers_in_json,
6+
)
7+
8+
__all__ = [
9+
"DEFAULT_CHAPTER_INTEGER_JSON_KEYS",
10+
"renumber_chapter_integers_in_json",
11+
]
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""在任意 JSON 子树中按「键名约定」改写章号整数,避免为每种快照结构写死解析代码。"""
2+
from __future__ import annotations
3+
4+
from typing import Any, FrozenSet
5+
6+
from domain.novel.value_objects.chapter_renumber_spec import ChapterRenumberSpec
7+
8+
# 默认识别的「整型章号」字段名;新表/新 JSON 只需把键加进集合或通过协调器注入扩展键。
9+
DEFAULT_CHAPTER_INTEGER_JSON_KEYS: FrozenSet[str] = frozenset(
10+
{
11+
"chapter_number",
12+
"chapter",
13+
"planted_in_chapter",
14+
"resolved_in_chapter",
15+
"suggested_resolve_chapter",
16+
"consumed_at_chapter",
17+
"first_appearance",
18+
"target_chapter_start",
19+
"target_chapter_end",
20+
"estimated_chapter_start",
21+
"estimated_chapter_end",
22+
"current_chapter",
23+
"last_updated_chapter",
24+
"resolve_chapter_window",
25+
}
26+
)
27+
28+
29+
def renumber_chapter_integers_in_json(
30+
obj: Any,
31+
spec: ChapterRenumberSpec,
32+
*,
33+
keys: FrozenSet[str] = DEFAULT_CHAPTER_INTEGER_JSON_KEYS,
34+
) -> Any:
35+
if isinstance(obj, dict):
36+
out = {}
37+
for k, v in obj.items():
38+
if k in keys and isinstance(v, int) and not isinstance(v, bool):
39+
out[k] = spec.shift_chapter_ref(v)
40+
else:
41+
out[k] = renumber_chapter_integers_in_json(v, spec, keys=keys)
42+
return out
43+
if isinstance(obj, list):
44+
return [renumber_chapter_integers_in_json(x, spec, keys=keys) for x in obj]
45+
if isinstance(obj, tuple):
46+
return tuple(renumber_chapter_integers_in_json(x, spec, keys=keys) for x in obj)
47+
return obj

0 commit comments

Comments
 (0)