novelws 5.2.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +51 -0
  3. package/dist/cli.js +3 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/dashboard.d.ts +3 -0
  6. package/dist/commands/dashboard.d.ts.map +1 -0
  7. package/dist/commands/dashboard.js +42 -0
  8. package/dist/commands/dashboard.js.map +1 -0
  9. package/dist/commands/init.d.ts.map +1 -1
  10. package/dist/commands/init.js +19 -0
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/upgrade.d.ts.map +1 -1
  13. package/dist/commands/upgrade.js +23 -0
  14. package/dist/commands/upgrade.js.map +1 -1
  15. package/dist/core/config.d.ts +3 -0
  16. package/dist/core/config.d.ts.map +1 -1
  17. package/dist/core/config.js +4 -0
  18. package/dist/core/config.js.map +1 -1
  19. package/dist/server/datasource/db.d.ts +38 -0
  20. package/dist/server/datasource/db.d.ts.map +1 -0
  21. package/dist/server/datasource/db.js +323 -0
  22. package/dist/server/datasource/db.js.map +1 -0
  23. package/dist/server/datasource/fs.d.ts +30 -0
  24. package/dist/server/datasource/fs.d.ts.map +1 -0
  25. package/dist/server/datasource/fs.js +308 -0
  26. package/dist/server/datasource/fs.js.map +1 -0
  27. package/dist/server/datasource/index.d.ts +11 -0
  28. package/dist/server/datasource/index.d.ts.map +1 -0
  29. package/dist/server/datasource/index.js +33 -0
  30. package/dist/server/datasource/index.js.map +1 -0
  31. package/dist/server/index.d.ts +12 -0
  32. package/dist/server/index.d.ts.map +1 -0
  33. package/dist/server/index.js +69 -0
  34. package/dist/server/index.js.map +1 -0
  35. package/dist/server/routes/characters.d.ts +4 -0
  36. package/dist/server/routes/characters.d.ts.map +1 -0
  37. package/dist/server/routes/characters.js +27 -0
  38. package/dist/server/routes/characters.js.map +1 -0
  39. package/dist/server/routes/plots.d.ts +4 -0
  40. package/dist/server/routes/plots.d.ts.map +1 -0
  41. package/dist/server/routes/plots.js +36 -0
  42. package/dist/server/routes/plots.js.map +1 -0
  43. package/dist/server/routes/protagonist.d.ts +4 -0
  44. package/dist/server/routes/protagonist.d.ts.map +1 -0
  45. package/dist/server/routes/protagonist.js +46 -0
  46. package/dist/server/routes/protagonist.js.map +1 -0
  47. package/dist/server/routes/relationships.d.ts +4 -0
  48. package/dist/server/routes/relationships.d.ts.map +1 -0
  49. package/dist/server/routes/relationships.js +27 -0
  50. package/dist/server/routes/relationships.js.map +1 -0
  51. package/dist/server/routes/stats.d.ts +4 -0
  52. package/dist/server/routes/stats.d.ts.map +1 -0
  53. package/dist/server/routes/stats.js +21 -0
  54. package/dist/server/routes/stats.js.map +1 -0
  55. package/dist/server/routes/stories.d.ts +4 -0
  56. package/dist/server/routes/stories.d.ts.map +1 -0
  57. package/dist/server/routes/stories.js +80 -0
  58. package/dist/server/routes/stories.js.map +1 -0
  59. package/dist/server/routes/timeline.d.ts +4 -0
  60. package/dist/server/routes/timeline.d.ts.map +1 -0
  61. package/dist/server/routes/timeline.js +17 -0
  62. package/dist/server/routes/timeline.js.map +1 -0
  63. package/dist/server/types.d.ts +199 -0
  64. package/dist/server/types.d.ts.map +1 -0
  65. package/dist/server/types.js +2 -0
  66. package/dist/server/types.js.map +1 -0
  67. package/package.json +13 -3
  68. package/templates/commands/analyze.md +9 -1
  69. package/templates/commands/expand.md +19 -3
  70. package/templates/commands/write.md +23 -7
  71. package/templates/dot-claude/CLAUDE.md +27 -0
  72. package/templates/scripts/db_context.py +609 -0
  73. package/templates/scripts/db_init_protagonist.py +343 -0
  74. package/templates/scripts/db_sync.py +611 -0
  75. package/templates/scripts/db_volume_switch.py +278 -0
  76. package/templates/scripts/phase_a_init_db.py +428 -0
  77. package/templates/scripts/requirements.txt +1 -0
  78. package/templates/tracking/character-state.json +1 -3
  79. package/templates/tracking/plot-tracker.json +1 -5
  80. package/templates/tracking/relationships.json +1 -3
  81. package/templates/tracking/timeline.json +1 -3
  82. package/templates/volume-outline.md +31 -0
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env python3
2
+ """db_volume_switch.py — 从 DB 生成 volume-summary.md
3
+
4
+ 用法:
5
+ python scripts/db_volume_switch.py --vol 2 # 为 vol-002 生成 volume-summary
6
+ python scripts/db_volume_switch.py --vol 2 --dry # 预览输出,不写入文件
7
+
8
+ 从 DB 查询上一卷的角色状态、伏笔、关系、时间线,生成新卷的 volume-summary.md。
9
+ 替代原有的"读取4个 tracking JSON"卷切换流程。
10
+ """
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import sys
16
+ import psycopg2
17
+
18
+ BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
19
+ SCHEMA = "novelws"
20
+
21
+
22
+ def load_config():
23
+ config_path = os.path.join(BASE, "resources", "config.json")
24
+ with open(config_path, "r", encoding="utf-8") as f:
25
+ return json.load(f)
26
+
27
+
28
+ def get_db_config(config):
29
+ db = config.get("database", {})
30
+ return {
31
+ "host": db.get("host", "127.0.0.1"),
32
+ "port": db.get("port", 5432),
33
+ "dbname": db.get("dbname", "postgres"),
34
+ "user": db.get("user", "postgres"),
35
+ "password": db.get("password", ""),
36
+ }
37
+
38
+
39
+ def get_story_path(config):
40
+ story_name = config.get("story", "")
41
+ if not story_name:
42
+ stories_dir = os.path.join(BASE, "stories")
43
+ if os.path.isdir(stories_dir):
44
+ dirs = [d for d in os.listdir(stories_dir)
45
+ if os.path.isdir(os.path.join(stories_dir, d))]
46
+ if dirs:
47
+ story_name = dirs[0]
48
+ return os.path.join(BASE, "stories", story_name)
49
+
50
+
51
+ def generate_volume_summary(cur, target_vol):
52
+ """从 DB 生成 volume-summary.md 内容"""
53
+ prev_vol = target_vol - 1
54
+ lines = []
55
+
56
+ # 获取目标卷信息
57
+ cur.execute(f"""
58
+ SELECT v.title, p.phase_number, p.title as phase_title
59
+ FROM {SCHEMA}.volumes v JOIN {SCHEMA}.phases p ON v.phase_id = p.id
60
+ WHERE v.vol_number = %s
61
+ """, (target_vol,))
62
+ target_info = cur.fetchone()
63
+ target_title = target_info[0] if target_info else f"vol-{target_vol:03d}"
64
+ phase_num = target_info[1] if target_info else "?"
65
+ phase_title = target_info[2] if target_info else "?"
66
+
67
+ lines.append(f"# Volume Summary — vol-{target_vol:03d} {target_title}")
68
+ lines.append(f"纪元{phase_num} [{phase_title}]")
69
+ lines.append("")
70
+
71
+ # 1. 故事进度
72
+ lines.append("## 故事进度")
73
+ cur.execute(f"""
74
+ SELECT v.vol_number, v.title,
75
+ COUNT(ch.id) as total_ch,
76
+ COUNT(CASE WHEN ch.status IN ('drafted','final') THEN 1 END) as written_ch,
77
+ COALESCE(SUM(ch.word_count), 0) as words
78
+ FROM {SCHEMA}.volumes v
79
+ LEFT JOIN {SCHEMA}.chapters ch ON ch.volume_id = v.id
80
+ WHERE v.vol_number <= %s
81
+ GROUP BY v.vol_number, v.title
82
+ ORDER BY v.vol_number
83
+ """, (prev_vol,))
84
+ rows = cur.fetchall()
85
+ for r in rows:
86
+ status = "已完成" if r[3] == r[2] and r[2] > 0 else f"进行中({r[3]}/{r[2]}章)"
87
+ lines.append(f"- vol-{r[0]:03d} {r[1]}: {status}, {r[4]:,}字")
88
+ lines.append("")
89
+
90
+ # 2. 活跃角色状态(从最近2卷提取)
91
+ lines.append("## 活跃角色状态")
92
+ cur.execute(f"""
93
+ SELECT DISTINCT ON (c.name)
94
+ c.name, c.role_type, c.status, cs.location, cs.state_summary, cs.last_appearance,
95
+ v.vol_number
96
+ FROM {SCHEMA}.characters c
97
+ JOIN {SCHEMA}.character_states cs ON c.id = cs.character_id
98
+ JOIN {SCHEMA}.volumes v ON cs.volume_id = v.id
99
+ WHERE v.vol_number BETWEEN %s AND %s AND c.status != 'dead'
100
+ ORDER BY c.name, v.vol_number DESC, cs.last_appearance DESC
101
+ """, (max(1, prev_vol - 1), prev_vol))
102
+ rows = cur.fetchall()
103
+ if rows:
104
+ for r in rows:
105
+ summary = r[4] or "-"
106
+ if len(summary) > 100:
107
+ summary = summary[:100] + "…"
108
+ lines.append(f"- **{r[0]}** ({r[1]}, {r[2]}): {r[3] or '?'} — {summary} [vol-{r[6]:03d} ch-{r[5]}]")
109
+ else:
110
+ lines.append("(无角色数据)")
111
+ lines.append("")
112
+
113
+ # 已故角色(单独列出)
114
+ cur.execute(f"""
115
+ SELECT c.name, cs.state_summary, cs.last_appearance, v.vol_number
116
+ FROM {SCHEMA}.characters c
117
+ JOIN {SCHEMA}.character_states cs ON c.id = cs.character_id
118
+ JOIN {SCHEMA}.volumes v ON cs.volume_id = v.id
119
+ WHERE c.status = 'dead' AND v.vol_number <= %s
120
+ ORDER BY cs.last_appearance DESC
121
+ """, (prev_vol,))
122
+ dead_rows = cur.fetchall()
123
+ if dead_rows:
124
+ lines.append("### 已故/退场角色")
125
+ for r in dead_rows:
126
+ summary = r[1] or "-"
127
+ if len(summary) > 80:
128
+ summary = summary[:80] + "…"
129
+ lines.append(f"- {r[0]}: {summary} [vol-{r[3]:03d} ch-{r[2]}]")
130
+ lines.append("")
131
+
132
+ # 3. 活跃伏笔
133
+ lines.append("## 活跃伏笔")
134
+ cur.execute(f"""
135
+ SELECT f.code, f.description, f.status, ch.chapter_number, v.vol_number, f.note
136
+ FROM {SCHEMA}.foreshadowing f
137
+ JOIN {SCHEMA}.chapters ch ON f.planted_chapter_id = ch.id
138
+ JOIN {SCHEMA}.volumes v ON ch.volume_id = v.id
139
+ WHERE f.status IN ('planted', 'hinted', 'partially_resolved')
140
+ ORDER BY v.vol_number, ch.chapter_number
141
+ """)
142
+ rows = cur.fetchall()
143
+ if rows:
144
+ lines.append(f"共 {len(rows)} 条未解决:")
145
+ for r in rows:
146
+ note = f" ({r[5]})" if r[5] else ""
147
+ lines.append(f"- [{r[0]}] vol-{r[4]:03d} ch-{r[3]:03d} [{r[2]}]: {r[1]}{note}")
148
+ else:
149
+ lines.append("无活跃伏笔")
150
+ lines.append("")
151
+
152
+ # 4. 关键关系
153
+ lines.append("## 关键关系")
154
+ cur.execute(f"""
155
+ SELECT ca.name, cb.name, r.relationship_type, r.current_status
156
+ FROM {SCHEMA}.relationships r
157
+ JOIN {SCHEMA}.characters ca ON r.character_a_id = ca.id
158
+ JOIN {SCHEMA}.characters cb ON r.character_b_id = cb.id
159
+ JOIN {SCHEMA}.volumes v ON r.volume_id = v.id
160
+ WHERE v.vol_number = %s
161
+ ORDER BY r.last_update_chapter DESC
162
+ """, (prev_vol,))
163
+ rows = cur.fetchall()
164
+ if rows:
165
+ for r in rows:
166
+ status = r[3] or "-"
167
+ if len(status) > 80:
168
+ status = status[:80] + "…"
169
+ lines.append(f"- {r[0]} → {r[1]} [{r[2]}]: {status}")
170
+ else:
171
+ lines.append("(无关系数据)")
172
+ lines.append("")
173
+
174
+ # 5. 待续悬念(上一卷末章钩子)
175
+ lines.append("## 待续悬念")
176
+ cur.execute(f"""
177
+ SELECT ch.chapter_number, te.event_description
178
+ FROM {SCHEMA}.timeline_events te
179
+ JOIN {SCHEMA}.chapters ch ON te.chapter_id = ch.id
180
+ JOIN {SCHEMA}.volumes v ON te.volume_id = v.id
181
+ WHERE v.vol_number = %s
182
+ ORDER BY ch.chapter_number DESC
183
+ LIMIT 3
184
+ """, (prev_vol,))
185
+ rows = cur.fetchall()
186
+ if rows:
187
+ lines.append("上一卷末尾事件:")
188
+ for r in reversed(rows):
189
+ lines.append(f"- ch-{r[0]:03d}: {r[1]}")
190
+ else:
191
+ lines.append("(无上一卷数据)")
192
+ lines.append("")
193
+
194
+ # 6. 活跃情节线
195
+ lines.append("## 活跃情节线")
196
+ cur.execute(f"""
197
+ SELECT name, status, description
198
+ FROM {SCHEMA}.plot_threads
199
+ WHERE status NOT IN ('已完成')
200
+ ORDER BY id
201
+ """)
202
+ rows = cur.fetchall()
203
+ if rows:
204
+ for r in rows:
205
+ desc = r[2] or "-"
206
+ if len(desc) > 100:
207
+ desc = desc[:100] + "…"
208
+ lines.append(f"- **{r[0]}** [{r[1]}]: {desc}")
209
+ else:
210
+ lines.append("无活跃情节线")
211
+ lines.append("")
212
+
213
+ # 7. 主角状态
214
+ lines.append("## 主角状态")
215
+ cur.execute(f"""
216
+ SELECT level, progress_pct FROM {SCHEMA}.cultivation_curve
217
+ ORDER BY chapter_number DESC LIMIT 1
218
+ """)
219
+ cult = cur.fetchone()
220
+ if cult:
221
+ lines.append(f"- 修炼: {cult[0]} ({cult[1]}%)")
222
+
223
+ cur.execute(f"""
224
+ SELECT skill_name, skill_category FROM {SCHEMA}.skill_overview
225
+ WHERE status = 'active' ORDER BY acquired_chapter
226
+ """)
227
+ skills = cur.fetchall()
228
+ if skills:
229
+ skill_strs = [f"{s[0]}[{s[1]}]" for s in skills]
230
+ lines.append(f"- 技能清单: {', '.join(skill_strs)}")
231
+
232
+ cur.execute(f"""
233
+ SELECT item_name, quantity FROM {SCHEMA}.current_inventory
234
+ ORDER BY item_type
235
+ """)
236
+ items = cur.fetchall()
237
+ if items:
238
+ item_strs = [f"{r[0]}×{r[1]}" if r[1] > 1 else r[0] for r in items]
239
+ lines.append(f"- 关键道具: {'、'.join(item_strs)}")
240
+ lines.append("")
241
+
242
+ return "\n".join(lines)
243
+
244
+
245
+ def main():
246
+ parser = argparse.ArgumentParser(description="从 DB 生成 volume-summary.md")
247
+ parser.add_argument("--vol", type=int, required=True, help="目标卷号")
248
+ parser.add_argument("--dry", action="store_true", help="预览输出,不写入文件")
249
+ args = parser.parse_args()
250
+
251
+ config = load_config()
252
+ db_config = get_db_config(config)
253
+ story_path = get_story_path(config)
254
+ volumes_dir = os.path.join(story_path, "volumes")
255
+
256
+ conn = psycopg2.connect(**db_config)
257
+ cur = conn.cursor()
258
+
259
+ try:
260
+ content = generate_volume_summary(cur, args.vol)
261
+
262
+ if args.dry:
263
+ print(content)
264
+ else:
265
+ vol_dir = os.path.join(volumes_dir, f"vol-{args.vol:03d}")
266
+ os.makedirs(vol_dir, exist_ok=True)
267
+ out_path = os.path.join(vol_dir, "volume-summary.md")
268
+ with open(out_path, "w", encoding="utf-8") as f:
269
+ f.write(content)
270
+ print(f"已生成: {out_path}")
271
+ print(f"字数: {len(content)} 字符")
272
+ finally:
273
+ cur.close()
274
+ conn.close()
275
+
276
+
277
+ if __name__ == "__main__":
278
+ main()
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env python3
2
+ """Phase A: 小说数据库初始化 — 建表 + 视图
3
+
4
+ 用法:
5
+ python scripts/phase_a_init_db.py # 创建 schema + 表 + 视图
6
+ python scripts/phase_a_init_db.py --seed # 建表后运行 db_sync.py --all 导入数据
7
+
8
+ 依赖: pip install -r scripts/requirements.txt
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ import psycopg2
16
+
17
+ BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18
+ SCHEMA = "novelws"
19
+
20
+
21
+ def load_config():
22
+ config_path = os.path.join(BASE, "resources", "config.json")
23
+ with open(config_path, "r", encoding="utf-8") as f:
24
+ return json.load(f)
25
+
26
+
27
+ def get_db_config(config):
28
+ db = config.get("database", {})
29
+ return {
30
+ "host": db.get("host", "127.0.0.1"),
31
+ "port": db.get("port", 5432),
32
+ "dbname": db.get("dbname", "postgres"),
33
+ "user": db.get("user", "postgres"),
34
+ "password": db.get("password", ""),
35
+ }
36
+
37
+
38
+ # ─────────────────────────────────────────────
39
+ # DDL: 建 schema + 20 张核心表
40
+ # ─────────────────────────────────────────────
41
+ DDL = f"""
42
+ DROP SCHEMA IF EXISTS {SCHEMA} CASCADE;
43
+ CREATE SCHEMA {SCHEMA};
44
+
45
+ -- 1) phases: 纪元
46
+ CREATE TABLE {SCHEMA}.phases (
47
+ id SERIAL PRIMARY KEY,
48
+ phase_number INT NOT NULL UNIQUE,
49
+ title VARCHAR(100) NOT NULL,
50
+ theme VARCHAR(200),
51
+ vol_start INT NOT NULL,
52
+ vol_end INT NOT NULL,
53
+ protagonist_level_start VARCHAR(50),
54
+ protagonist_level_end VARCHAR(50),
55
+ stage_level VARCHAR(50),
56
+ growth_keyword VARCHAR(200),
57
+ core_conflict TEXT,
58
+ status VARCHAR(20) DEFAULT 'planned'
59
+ );
60
+
61
+ -- 2) volumes: 卷
62
+ CREATE TABLE {SCHEMA}.volumes (
63
+ id SERIAL PRIMARY KEY,
64
+ phase_id INT NOT NULL REFERENCES {SCHEMA}.phases(id),
65
+ vol_number INT NOT NULL UNIQUE,
66
+ title VARCHAR(200) NOT NULL,
67
+ arc_type VARCHAR(50),
68
+ instance_name VARCHAR(100),
69
+ instance_type VARCHAR(100),
70
+ instance_level VARCHAR(50),
71
+ protagonist_level_range VARCHAR(100),
72
+ chapter_count INT DEFAULT 100,
73
+ outline_path TEXT,
74
+ summary_path TEXT,
75
+ status VARCHAR(20) DEFAULT 'planned'
76
+ );
77
+
78
+ -- 3) chapters: 章节
79
+ CREATE TABLE {SCHEMA}.chapters (
80
+ id SERIAL PRIMARY KEY,
81
+ volume_id INT NOT NULL REFERENCES {SCHEMA}.volumes(id),
82
+ chapter_number INT NOT NULL,
83
+ global_chapter_number INT NOT NULL UNIQUE,
84
+ title VARCHAR(300),
85
+ synopsis_path TEXT,
86
+ content_path TEXT,
87
+ synopsis_summary TEXT,
88
+ synopsis_keywords TEXT[],
89
+ status VARCHAR(20) DEFAULT 'planned',
90
+ word_count INT DEFAULT 0,
91
+ scene_location VARCHAR(200),
92
+ time_in_story VARCHAR(200),
93
+ pov_character VARCHAR(100) DEFAULT '主角'
94
+ );
95
+
96
+ -- 4) characters: 角色主表
97
+ CREATE TABLE {SCHEMA}.characters (
98
+ id SERIAL PRIMARY KEY,
99
+ name VARCHAR(100) NOT NULL UNIQUE,
100
+ aliases TEXT[],
101
+ role_type VARCHAR(50),
102
+ first_appearance_vol INT,
103
+ first_appearance_ch INT,
104
+ cultivation_level VARCHAR(100),
105
+ faction VARCHAR(200),
106
+ status VARCHAR(20) DEFAULT 'active'
107
+ );
108
+
109
+ -- 5) character_states: 角色状态快照(按卷)
110
+ CREATE TABLE {SCHEMA}.character_states (
111
+ id SERIAL PRIMARY KEY,
112
+ character_id INT NOT NULL REFERENCES {SCHEMA}.characters(id),
113
+ volume_id INT NOT NULL REFERENCES {SCHEMA}.volumes(id),
114
+ cultivation_level VARCHAR(100),
115
+ location VARCHAR(200),
116
+ state_summary TEXT,
117
+ last_appearance INT,
118
+ UNIQUE(character_id, volume_id)
119
+ );
120
+
121
+ -- 6) plot_threads: 情节线
122
+ CREATE TABLE {SCHEMA}.plot_threads (
123
+ id SERIAL PRIMARY KEY,
124
+ name VARCHAR(200) NOT NULL,
125
+ thread_type VARCHAR(50),
126
+ status VARCHAR(30) DEFAULT 'active',
127
+ description TEXT,
128
+ start_volume_id INT REFERENCES {SCHEMA}.volumes(id),
129
+ volume_id INT REFERENCES {SCHEMA}.volumes(id),
130
+ key_events JSONB DEFAULT '[]'::jsonb
131
+ );
132
+
133
+ -- 7) foreshadowing: 伏笔
134
+ CREATE TABLE {SCHEMA}.foreshadowing (
135
+ id SERIAL PRIMARY KEY,
136
+ code VARCHAR(20) UNIQUE,
137
+ plot_thread_id INT REFERENCES {SCHEMA}.plot_threads(id),
138
+ description TEXT NOT NULL,
139
+ planted_chapter_id INT REFERENCES {SCHEMA}.chapters(id),
140
+ hinted_chapter_id INT REFERENCES {SCHEMA}.chapters(id),
141
+ resolved_chapter_id INT REFERENCES {SCHEMA}.chapters(id),
142
+ status VARCHAR(30) DEFAULT 'planted',
143
+ importance VARCHAR(20) DEFAULT 'major',
144
+ note TEXT
145
+ );
146
+
147
+ -- 8) chapter_foreshadowing: 伏笔×章节关联
148
+ CREATE TABLE {SCHEMA}.chapter_foreshadowing (
149
+ id SERIAL PRIMARY KEY,
150
+ foreshadowing_id INT NOT NULL REFERENCES {SCHEMA}.foreshadowing(id),
151
+ chapter_id INT NOT NULL REFERENCES {SCHEMA}.chapters(id),
152
+ action_type VARCHAR(20) NOT NULL,
153
+ UNIQUE(foreshadowing_id, chapter_id, action_type)
154
+ );
155
+
156
+ -- 9) chapter_participants: 章节×角色关联
157
+ CREATE TABLE {SCHEMA}.chapter_participants (
158
+ id SERIAL PRIMARY KEY,
159
+ chapter_id INT NOT NULL REFERENCES {SCHEMA}.chapters(id),
160
+ character_id INT NOT NULL REFERENCES {SCHEMA}.characters(id),
161
+ role_in_chapter VARCHAR(50),
162
+ UNIQUE(chapter_id, character_id)
163
+ );
164
+
165
+ -- 10) relationships: 角色关系
166
+ CREATE TABLE {SCHEMA}.relationships (
167
+ id SERIAL PRIMARY KEY,
168
+ character_a_id INT NOT NULL REFERENCES {SCHEMA}.characters(id),
169
+ character_b_id INT NOT NULL REFERENCES {SCHEMA}.characters(id),
170
+ relationship_type VARCHAR(100),
171
+ current_status TEXT,
172
+ note TEXT,
173
+ last_update_chapter INT,
174
+ volume_id INT REFERENCES {SCHEMA}.volumes(id),
175
+ UNIQUE(character_a_id, character_b_id, volume_id)
176
+ );
177
+
178
+ -- 11) timeline_events: 时间线
179
+ CREATE TABLE {SCHEMA}.timeline_events (
180
+ id SERIAL PRIMARY KEY,
181
+ chapter_id INT NOT NULL REFERENCES {SCHEMA}.chapters(id),
182
+ event_description TEXT NOT NULL,
183
+ story_time VARCHAR(200),
184
+ location VARCHAR(200),
185
+ tags TEXT[] DEFAULT '{{}}',
186
+ volume_id INT NOT NULL REFERENCES {SCHEMA}.volumes(id)
187
+ );
188
+
189
+ -- 12) cross_volume_refs: 跨卷引用
190
+ CREATE TABLE {SCHEMA}.cross_volume_refs (
191
+ id SERIAL PRIMARY KEY,
192
+ source_chapter_id INT NOT NULL REFERENCES {SCHEMA}.chapters(id),
193
+ target_chapter_id INT NOT NULL REFERENCES {SCHEMA}.chapters(id),
194
+ ref_type VARCHAR(50),
195
+ description TEXT
196
+ );
197
+
198
+ -- 13) character_key_changes: 角色章级变更历史
199
+ CREATE TABLE {SCHEMA}.character_key_changes (
200
+ id SERIAL PRIMARY KEY,
201
+ character_id INT NOT NULL REFERENCES {SCHEMA}.characters(id),
202
+ chapter_number INT NOT NULL,
203
+ change_desc TEXT NOT NULL,
204
+ volume_id INT NOT NULL REFERENCES {SCHEMA}.volumes(id)
205
+ );
206
+
207
+ -- 14) relationship_history: 关系变迁历史
208
+ CREATE TABLE {SCHEMA}.relationship_history (
209
+ id SERIAL PRIMARY KEY,
210
+ relationship_id INT NOT NULL REFERENCES {SCHEMA}.relationships(id),
211
+ chapter_number INT NOT NULL,
212
+ status_desc TEXT NOT NULL,
213
+ volume_id INT NOT NULL REFERENCES {SCHEMA}.volumes(id)
214
+ );
215
+
216
+ -- 15) plot_thread_events: 情节线关键事件(展开表)
217
+ CREATE TABLE {SCHEMA}.plot_thread_events (
218
+ id SERIAL PRIMARY KEY,
219
+ plot_thread_id INT NOT NULL REFERENCES {SCHEMA}.plot_threads(id),
220
+ chapter_number INT NOT NULL,
221
+ event_desc TEXT NOT NULL,
222
+ volume_id INT NOT NULL REFERENCES {SCHEMA}.volumes(id)
223
+ );
224
+
225
+ -- 索引
226
+ CREATE INDEX idx_chapters_volume ON {SCHEMA}.chapters(volume_id);
227
+ CREATE INDEX idx_chapters_global ON {SCHEMA}.chapters(global_chapter_number);
228
+ CREATE INDEX idx_char_states_vol ON {SCHEMA}.character_states(volume_id);
229
+ CREATE INDEX idx_foreshadowing_status ON {SCHEMA}.foreshadowing(status);
230
+ CREATE INDEX idx_timeline_chapter ON {SCHEMA}.timeline_events(chapter_id);
231
+ CREATE INDEX idx_timeline_tags ON {SCHEMA}.timeline_events USING GIN(tags);
232
+ CREATE INDEX idx_plot_threads_status ON {SCHEMA}.plot_threads(status);
233
+ CREATE INDEX idx_plot_threads_vol ON {SCHEMA}.plot_threads(volume_id);
234
+ CREATE INDEX idx_char_key_changes_char ON {SCHEMA}.character_key_changes(character_id);
235
+ CREATE INDEX idx_char_key_changes_vol ON {SCHEMA}.character_key_changes(volume_id);
236
+ CREATE INDEX idx_rel_history_rel ON {SCHEMA}.relationship_history(relationship_id);
237
+ CREATE INDEX idx_rel_history_vol ON {SCHEMA}.relationship_history(volume_id);
238
+ CREATE INDEX idx_plot_events_thread ON {SCHEMA}.plot_thread_events(plot_thread_id);
239
+ CREATE INDEX idx_plot_events_vol ON {SCHEMA}.plot_thread_events(volume_id);
240
+
241
+ -- 16) protagonist_skills: 主角技能主表
242
+ CREATE TABLE {SCHEMA}.protagonist_skills (
243
+ id SERIAL PRIMARY KEY,
244
+ skill_name VARCHAR(100) NOT NULL UNIQUE,
245
+ skill_category VARCHAR(20) NOT NULL,
246
+ skill_level VARCHAR(50),
247
+ parent_skill_id INT REFERENCES {SCHEMA}.protagonist_skills(id),
248
+ description TEXT,
249
+ acquired_chapter INT NOT NULL,
250
+ acquired_method VARCHAR(200),
251
+ last_used_chapter INT,
252
+ use_count INT DEFAULT 0,
253
+ status VARCHAR(20) DEFAULT 'active'
254
+ );
255
+
256
+ -- 17) protagonist_skill_events: 技能时序表
257
+ CREATE TABLE {SCHEMA}.protagonist_skill_events (
258
+ id SERIAL PRIMARY KEY,
259
+ skill_id INT NOT NULL REFERENCES {SCHEMA}.protagonist_skills(id),
260
+ chapter_number INT NOT NULL,
261
+ event_type VARCHAR(30) NOT NULL,
262
+ detail TEXT,
263
+ volume_id INT NOT NULL REFERENCES {SCHEMA}.volumes(id)
264
+ );
265
+
266
+ -- 18) protagonist_inventory: 装备/道具主表
267
+ CREATE TABLE {SCHEMA}.protagonist_inventory (
268
+ id SERIAL PRIMARY KEY,
269
+ item_name VARCHAR(100) NOT NULL,
270
+ item_type VARCHAR(30) NOT NULL,
271
+ quantity INT DEFAULT 1,
272
+ quality VARCHAR(30),
273
+ description TEXT,
274
+ acquired_chapter INT NOT NULL,
275
+ acquired_method VARCHAR(200),
276
+ status VARCHAR(20) DEFAULT 'held',
277
+ UNIQUE(item_name, acquired_chapter)
278
+ );
279
+
280
+ -- 19) protagonist_inventory_events: 道具时序表
281
+ CREATE TABLE {SCHEMA}.protagonist_inventory_events (
282
+ id SERIAL PRIMARY KEY,
283
+ item_id INT NOT NULL REFERENCES {SCHEMA}.protagonist_inventory(id),
284
+ chapter_number INT NOT NULL,
285
+ event_type VARCHAR(30) NOT NULL,
286
+ quantity_change INT DEFAULT 0,
287
+ detail TEXT,
288
+ volume_id INT NOT NULL REFERENCES {SCHEMA}.volumes(id)
289
+ );
290
+
291
+ -- 20) protagonist_cultivation: 修炼进度表
292
+ CREATE TABLE {SCHEMA}.protagonist_cultivation (
293
+ id SERIAL PRIMARY KEY,
294
+ chapter_number INT NOT NULL,
295
+ level VARCHAR(50) NOT NULL,
296
+ progress_pct DECIMAL(5,1),
297
+ breakthrough_type VARCHAR(20),
298
+ trigger VARCHAR(200),
299
+ detail TEXT,
300
+ volume_id INT NOT NULL REFERENCES {SCHEMA}.volumes(id)
301
+ );
302
+
303
+ -- protagonist 索引
304
+ CREATE INDEX idx_skills_category ON {SCHEMA}.protagonist_skills(skill_category);
305
+ CREATE INDEX idx_skills_status ON {SCHEMA}.protagonist_skills(status);
306
+ CREATE INDEX idx_skill_events_skill ON {SCHEMA}.protagonist_skill_events(skill_id);
307
+ CREATE INDEX idx_skill_events_vol ON {SCHEMA}.protagonist_skill_events(volume_id);
308
+ CREATE INDEX idx_skill_events_type ON {SCHEMA}.protagonist_skill_events(event_type);
309
+ CREATE INDEX idx_inventory_type ON {SCHEMA}.protagonist_inventory(item_type);
310
+ CREATE INDEX idx_inventory_status ON {SCHEMA}.protagonist_inventory(status);
311
+ CREATE INDEX idx_inv_events_item ON {SCHEMA}.protagonist_inventory_events(item_id);
312
+ CREATE INDEX idx_inv_events_vol ON {SCHEMA}.protagonist_inventory_events(volume_id);
313
+ CREATE INDEX idx_cultivation_vol ON {SCHEMA}.protagonist_cultivation(volume_id);
314
+ CREATE INDEX idx_cultivation_ch ON {SCHEMA}.protagonist_cultivation(chapter_number);
315
+ """
316
+
317
+ VIEWS = f"""
318
+ CREATE OR REPLACE VIEW {SCHEMA}.writing_dashboard AS
319
+ SELECT
320
+ p.phase_number,
321
+ p.title AS phase_title,
322
+ COUNT(DISTINCT v.id) AS total_volumes,
323
+ COUNT(DISTINCT v.id) FILTER (WHERE v.status = 'completed') AS completed_volumes,
324
+ COUNT(DISTINCT ch.id) AS total_chapters,
325
+ COUNT(DISTINCT ch.id) FILTER (WHERE ch.status = 'final') AS final_chapters,
326
+ COUNT(DISTINCT ch.id) FILTER (WHERE ch.status = 'synopsis') AS synopsis_chapters,
327
+ SUM(ch.word_count) AS total_words
328
+ FROM {SCHEMA}.phases p
329
+ LEFT JOIN {SCHEMA}.volumes v ON v.phase_id = p.id
330
+ LEFT JOIN {SCHEMA}.chapters ch ON ch.volume_id = v.id
331
+ GROUP BY p.id, p.phase_number, p.title
332
+ ORDER BY p.phase_number;
333
+
334
+ CREATE OR REPLACE VIEW {SCHEMA}.open_foreshadowing AS
335
+ SELECT
336
+ f.code, f.description, f.status,
337
+ ch.chapter_number AS planted_at,
338
+ ch.time_in_story AS planted_time,
339
+ f.note
340
+ FROM {SCHEMA}.foreshadowing f
341
+ JOIN {SCHEMA}.chapters ch ON f.planted_chapter_id = ch.id
342
+ WHERE f.status IN ('planted', 'hinted', 'partially_resolved')
343
+ ORDER BY ch.chapter_number;
344
+
345
+ CREATE OR REPLACE VIEW {SCHEMA}.character_overview AS
346
+ SELECT
347
+ c.name, c.role_type, c.status,
348
+ cs.location, cs.state_summary, cs.last_appearance,
349
+ v.vol_number, v.title AS volume_title
350
+ FROM {SCHEMA}.characters c
351
+ LEFT JOIN {SCHEMA}.character_states cs ON c.id = cs.character_id
352
+ LEFT JOIN {SCHEMA}.volumes v ON cs.volume_id = v.id
353
+ ORDER BY c.name, v.vol_number;
354
+
355
+ CREATE OR REPLACE VIEW {SCHEMA}.current_inventory AS
356
+ SELECT item_name, item_type, quantity, quality, description, acquired_chapter
357
+ FROM {SCHEMA}.protagonist_inventory
358
+ WHERE status = 'held'
359
+ ORDER BY item_type, item_name;
360
+
361
+ CREATE OR REPLACE VIEW {SCHEMA}.skill_overview AS
362
+ SELECT s.skill_name, s.skill_category, s.skill_level, s.status,
363
+ s.acquired_chapter, s.use_count,
364
+ COUNT(e.id) AS total_events
365
+ FROM {SCHEMA}.protagonist_skills s
366
+ LEFT JOIN {SCHEMA}.protagonist_skill_events e ON s.id = e.skill_id
367
+ GROUP BY s.id
368
+ ORDER BY s.skill_category, s.acquired_chapter;
369
+
370
+ CREATE OR REPLACE VIEW {SCHEMA}.cultivation_curve AS
371
+ SELECT chapter_number, level, progress_pct, breakthrough_type, trigger
372
+ FROM {SCHEMA}.protagonist_cultivation
373
+ ORDER BY chapter_number;
374
+ """
375
+
376
+
377
+ def run():
378
+ config = load_config()
379
+ db_config = get_db_config(config)
380
+ conn = psycopg2.connect(**db_config)
381
+ conn.autocommit = False
382
+ cur = conn.cursor()
383
+
384
+ # ── Step 1: 建表 ──
385
+ print("[1/3] 创建 schema + 20 张表...")
386
+ cur.execute(DDL)
387
+ conn.commit()
388
+ print(" ✓ 表结构创建完成")
389
+
390
+ # ── Step 2: 创建视图 ──
391
+ print("[2/3] 创建常用视图...")
392
+ cur.execute(VIEWS)
393
+ conn.commit()
394
+ print(" ✓ 6 个视图创建完成")
395
+
396
+ # ── Step 3: 验证 ──
397
+ print("\n[3/3] 验证表结构...")
398
+ cur.execute(f"""
399
+ SELECT table_name FROM information_schema.tables
400
+ WHERE table_schema = '{SCHEMA}' AND table_type = 'BASE TABLE'
401
+ ORDER BY table_name
402
+ """)
403
+ tables = [row[0] for row in cur.fetchall()]
404
+ print(f" ✓ {len(tables)} 张表创建成功: {', '.join(tables)}")
405
+
406
+ cur.close()
407
+ conn.close()
408
+
409
+ print(f"\n✅ 数据库初始化完成 (schema: {SCHEMA})")
410
+ print(" 下一步: 运行 python scripts/db_sync.py --all 导入 tracking 数据")
411
+
412
+
413
+ def main():
414
+ import argparse
415
+ parser = argparse.ArgumentParser(description="小说数据库初始化 — 建表 + 视图")
416
+ parser.add_argument("--seed", action="store_true", help="建表后自动运行 db_sync.py --all")
417
+ args = parser.parse_args()
418
+
419
+ run()
420
+
421
+ if args.seed:
422
+ print("\n--- 自动导入数据 ---")
423
+ sync_script = os.path.join(BASE, "scripts", "db_sync.py")
424
+ subprocess.run([sys.executable, sync_script, "--all"], check=True)
425
+
426
+
427
+ if __name__ == "__main__":
428
+ main()