fabricatio-novel 0.1.1__cp313-cp313-win_amd64.whl
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.
Potentially problematic release.
This version of fabricatio-novel might be problematic. Click here for more details.
- fabricatio_novel/__init__.py +1 -0
- fabricatio_novel/actions/__init__.py +1 -0
- fabricatio_novel/actions/novel.py +337 -0
- fabricatio_novel/capabilities/__init__.py +1 -0
- fabricatio_novel/capabilities/novel.py +225 -0
- fabricatio_novel/cli.py +148 -0
- fabricatio_novel/config.py +30 -0
- fabricatio_novel/models/__init__.py +1 -0
- fabricatio_novel/models/novel.py +96 -0
- fabricatio_novel/models/scripting.py +76 -0
- fabricatio_novel/py.typed +0 -0
- fabricatio_novel/rust.cp313-win_amd64.pyd +0 -0
- fabricatio_novel/rust.pyi +120 -0
- fabricatio_novel/workflows/__init__.py +1 -0
- fabricatio_novel/workflows/novel.py +135 -0
- fabricatio_novel-0.1.1.dist-info/METADATA +88 -0
- fabricatio_novel-0.1.1.dist-info/RECORD +19 -0
- fabricatio_novel-0.1.1.dist-info/WHEEL +4 -0
- fabricatio_novel-0.1.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""An extension of fabricatio."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Actions defined in fabricatio-novel."""
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""This module provides actions related to novel generation and management.
|
|
2
|
+
|
|
3
|
+
It includes classes such as GenerateNovel for creating novels based on prompts,
|
|
4
|
+
and DumpNovel for saving generated novels to a specified file path. These actions
|
|
5
|
+
leverage capabilities from the fabricatio_core and interact with both Python and
|
|
6
|
+
Rust components to perform their tasks.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, ClassVar, List, Optional
|
|
11
|
+
|
|
12
|
+
from fabricatio_character.models.character import CharacterCard
|
|
13
|
+
from fabricatio_core import Action, logger
|
|
14
|
+
from fabricatio_core.utils import ok
|
|
15
|
+
|
|
16
|
+
from fabricatio_novel.capabilities.novel import NovelCompose
|
|
17
|
+
from fabricatio_novel.models.novel import Novel, NovelDraft
|
|
18
|
+
from fabricatio_novel.models.scripting import Script
|
|
19
|
+
from fabricatio_novel.rust import NovelBuilder
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GenerateCharactersFromDraft(NovelCompose, Action):
|
|
23
|
+
"""Generate character cards from a NovelDraft."""
|
|
24
|
+
|
|
25
|
+
novel_draft: Optional[NovelDraft] = None
|
|
26
|
+
"""
|
|
27
|
+
The novel draft from which to generate characters.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
output_key: str = "novel_characters"
|
|
31
|
+
"""
|
|
32
|
+
Key under which the generated list of CharacterCard will be stored in context.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
ctx_override: ClassVar[bool] = True
|
|
36
|
+
|
|
37
|
+
async def _execute(self, *_: Any, **cxt) -> List[CharacterCard] | None:
|
|
38
|
+
draft = ok(self.novel_draft, "`novel_draft` is required for character generation")
|
|
39
|
+
logger.info(f"Generating characters for novel draft: '{draft.title}'")
|
|
40
|
+
characters = await self.create_characters(draft)
|
|
41
|
+
if characters is None:
|
|
42
|
+
logger.warn("Character generation returned None.")
|
|
43
|
+
return None
|
|
44
|
+
valid_chars = [c for c in characters if c is not None]
|
|
45
|
+
logger.info(f"Generated {len(valid_chars)} valid character(s).")
|
|
46
|
+
return valid_chars
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class GenerateScriptsFromDraftAndCharacters(NovelCompose, Action):
|
|
50
|
+
"""Generate chapter scripts from a draft and list of characters."""
|
|
51
|
+
|
|
52
|
+
novel_draft: Optional[NovelDraft] = None
|
|
53
|
+
"""
|
|
54
|
+
The novel draft containing chapter synopses.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
novel_characters: Optional[List[CharacterCard]] = None # ← renamed for clarity & collision avoidance
|
|
58
|
+
"""
|
|
59
|
+
List of character cards to be used in script generation.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
output_key: str = "novel_scripts"
|
|
63
|
+
"""
|
|
64
|
+
Key under which the generated list of Script will be stored in context.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
ctx_override: ClassVar[bool] = True
|
|
68
|
+
|
|
69
|
+
async def _execute(self, *_: Any, **cxt) -> List[Script] | None:
|
|
70
|
+
draft = ok(self.novel_draft)
|
|
71
|
+
characters = ok(self.novel_characters) # ← consume from context as "novel_characters"
|
|
72
|
+
logger.info(f"Generating scripts for '{draft.title}' with {len(characters)} character(s).")
|
|
73
|
+
scripts = await self.create_scripts(draft, characters)
|
|
74
|
+
if scripts is None:
|
|
75
|
+
logger.warn("Script generation returned None.")
|
|
76
|
+
return None
|
|
77
|
+
valid_scripts = [s for s in scripts if s is not None]
|
|
78
|
+
logger.info(f"Generated {len(valid_scripts)} valid script(s).")
|
|
79
|
+
return valid_scripts
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class GenerateChaptersFromScripts(NovelCompose, Action):
|
|
83
|
+
"""Generate full chapter contents from scripts and characters."""
|
|
84
|
+
|
|
85
|
+
novel_draft: Optional[NovelDraft] = None
|
|
86
|
+
"""
|
|
87
|
+
The novel draft (for language, metadata).
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
novel_scripts: Optional[List[Script]] = None # ← renamed
|
|
91
|
+
"""
|
|
92
|
+
The list of chapter scripts to expand into full text.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
novel_characters: Optional[List[CharacterCard]] = None # ← renamed
|
|
96
|
+
"""
|
|
97
|
+
The list of characters to provide context.
|
|
98
|
+
"""
|
|
99
|
+
chapter_guidance: Optional[str] = None
|
|
100
|
+
"""
|
|
101
|
+
Guidance for writing chapter.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
output_key: str = "novel_chapter_contents"
|
|
105
|
+
"""
|
|
106
|
+
Key under which the generated list of chapter content strings will be stored in context.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
ctx_override: ClassVar[bool] = True
|
|
110
|
+
|
|
111
|
+
async def _execute(self, *_: Any, **cxt) -> List[str] | List[str | None] | None:
|
|
112
|
+
draft = ok(self.novel_draft)
|
|
113
|
+
scripts = ok(self.novel_scripts)
|
|
114
|
+
characters = ok(self.novel_characters)
|
|
115
|
+
|
|
116
|
+
logger.info(f"Generating {len(scripts)} chapter contents for '{draft.title}'.")
|
|
117
|
+
chapter_contents = await self.create_chapters(draft, scripts, characters, self.chapter_guidance)
|
|
118
|
+
if not chapter_contents:
|
|
119
|
+
logger.warn("Chapter content generation returned empty or None.")
|
|
120
|
+
return None
|
|
121
|
+
logger.info(f"Successfully generated {len(chapter_contents)} chapter content(s).")
|
|
122
|
+
return chapter_contents
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class AssembleNovelFromComponents(NovelCompose, Action):
|
|
126
|
+
"""Assemble final Novel object from draft, scripts, and chapter contents."""
|
|
127
|
+
|
|
128
|
+
novel_draft: Optional[NovelDraft] = None
|
|
129
|
+
"""
|
|
130
|
+
The original draft containing title, synopsis, etc.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
novel_scripts: Optional[List[Script]] = None # ← renamed
|
|
134
|
+
"""
|
|
135
|
+
Scripts containing chapter titles and metadata.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
novel_chapter_contents: Optional[List[str]] = None # ← renamed
|
|
139
|
+
"""
|
|
140
|
+
Generated full text for each chapter.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
output_key: str = "novel"
|
|
144
|
+
"""
|
|
145
|
+
Key under which the assembled Novel object will be stored in context.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
ctx_override: ClassVar[bool] = True
|
|
149
|
+
|
|
150
|
+
async def _execute(self, *_: Any, **cxt) -> Novel:
|
|
151
|
+
draft = ok(self.novel_draft)
|
|
152
|
+
scripts = ok(self.novel_scripts)
|
|
153
|
+
chapter_contents = ok(self.novel_chapter_contents)
|
|
154
|
+
|
|
155
|
+
logger.info("Assembling final novel from components...")
|
|
156
|
+
novel = self.assemble_novel(draft, scripts, chapter_contents)
|
|
157
|
+
logger.info(f"Novel '{novel.title}' assembled with {len(novel.chapters)} chapters.")
|
|
158
|
+
return novel
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class ValidateNovel(Action):
|
|
162
|
+
"""Validate the generated novel for compliance and structure."""
|
|
163
|
+
|
|
164
|
+
novel: Optional[Novel] = None
|
|
165
|
+
"""
|
|
166
|
+
The novel to validate.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
output_key: str = "novel_is_valid"
|
|
170
|
+
"""
|
|
171
|
+
Key under which the validation result (bool) will be stored in context.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
min_chapters: int = 1
|
|
175
|
+
min_total_words: int = 1000
|
|
176
|
+
min_compliance_ratio: float = 0.8
|
|
177
|
+
|
|
178
|
+
ctx_override: ClassVar[bool] = True
|
|
179
|
+
|
|
180
|
+
async def _execute(self, *_: Any, **cxt) -> bool:
|
|
181
|
+
novel = ok(self.novel)
|
|
182
|
+
|
|
183
|
+
issues = []
|
|
184
|
+
|
|
185
|
+
if len(novel.chapters) < self.min_chapters:
|
|
186
|
+
issues.append(f"Too few chapters: {len(novel.chapters)} < {self.min_chapters}")
|
|
187
|
+
|
|
188
|
+
if novel.exact_word_count < self.min_total_words:
|
|
189
|
+
issues.append(f"Too few words: {novel.exact_word_count} < {self.min_total_words}")
|
|
190
|
+
|
|
191
|
+
if novel.word_count_compliance_ratio < self.min_compliance_ratio:
|
|
192
|
+
issues.append(
|
|
193
|
+
f"Low compliance ratio: {novel.word_count_compliance_ratio:.2%} < {self.min_compliance_ratio:.2%}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if issues:
|
|
197
|
+
logger.warn(f"Novel validation failed for '{novel.title}': {'; '.join(issues)}")
|
|
198
|
+
return False
|
|
199
|
+
logger.info(f"Novel '{novel.title}' passed validation.")
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class GenerateNovelDraft(NovelCompose, Action):
|
|
204
|
+
"""Generate a novel draft from a prompt."""
|
|
205
|
+
|
|
206
|
+
novel_outline: Optional[str] = None
|
|
207
|
+
"""
|
|
208
|
+
The prompt used to generate the novel. If not provided, execution will fail.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
novel_language: Optional[str] = None
|
|
212
|
+
"""
|
|
213
|
+
The language of the novel. If not provided, will infer from the prompt.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
output_key: str = "novel_draft"
|
|
217
|
+
"""
|
|
218
|
+
Key under which the generated NovelDraft will be stored in context.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
ctx_override: ClassVar[bool] = True
|
|
222
|
+
|
|
223
|
+
async def _execute(self, *_: Any, **cxt) -> NovelDraft | None:
|
|
224
|
+
return await self.create_draft(outline=ok(self.novel_outline), language=self.novel_language)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class GenerateNovel(NovelCompose, Action):
|
|
228
|
+
"""An action that generates a novel based on a provided prompt.
|
|
229
|
+
|
|
230
|
+
This class inherits from NovelCompose and Action, and is responsible for
|
|
231
|
+
generating a novel using the underlying novel generation capability.
|
|
232
|
+
The generated novel is returned as a Novel object.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
novel_outline: Optional[str] = None
|
|
236
|
+
"""
|
|
237
|
+
The prompt used to generate the novel. If not provided, execution will fail.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
novel_language: Optional[str] = None
|
|
241
|
+
"""
|
|
242
|
+
The language of the novel. If not provided, will infer from the prompt.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
chapter_guidance: Optional[str] = None
|
|
246
|
+
"""
|
|
247
|
+
Guidance for writing chapter.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
output_key: str = "novel"
|
|
251
|
+
"""
|
|
252
|
+
The key under which the generated novel will be stored in the context.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
ctx_override: ClassVar[bool] = True
|
|
256
|
+
|
|
257
|
+
async def _execute(self, **cxt) -> Novel | None:
|
|
258
|
+
"""Execute the novel generation process.
|
|
259
|
+
|
|
260
|
+
Uses the provided novel_prompt to generate a novel via the inherited
|
|
261
|
+
novel() method from NovelCompose. Returns the generated Novel object.
|
|
262
|
+
|
|
263
|
+
Parameters:
|
|
264
|
+
**cxt: Contextual keyword arguments passed from the execution environment.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Novel | None: The generated novel object, or None if generation fails.
|
|
268
|
+
"""
|
|
269
|
+
return await self.compose_novel(ok(self.novel_outline), self.novel_language, self.chapter_guidance)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class DumpNovel(Action):
|
|
273
|
+
"""An action that saves a generated novel to a specified file path.
|
|
274
|
+
|
|
275
|
+
This class takes a Novel object and writes its content to a file at the
|
|
276
|
+
specified path.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
output_path: Optional[Path] = None
|
|
280
|
+
"""
|
|
281
|
+
The file system path where the novel should be saved. Required for execution.
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
novel_font_file: Optional[Path] = None
|
|
285
|
+
"""
|
|
286
|
+
The file system path to the novel font file. like .ttf file.
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
novel: Optional[Novel] = None
|
|
290
|
+
"""
|
|
291
|
+
The novel object to be saved. Must be provided for successful execution.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
cover_image: Optional[Path] = None
|
|
295
|
+
"""
|
|
296
|
+
The file system path to the novel cover image.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
output_key: str = "novel_path"
|
|
300
|
+
"""
|
|
301
|
+
The key under which the output path will be stored in the context.
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
ctx_override: ClassVar[bool] = True
|
|
305
|
+
|
|
306
|
+
async def _execute(self, *_: Any, **cxt) -> Path:
|
|
307
|
+
novel = ok(self.novel)
|
|
308
|
+
path = ok(self.output_path)
|
|
309
|
+
logger.info(
|
|
310
|
+
f"Novel word count: [{novel.exact_word_count}/{novel.expected_word_count}] | Compliance ratio: {novel.word_count_compliance_ratio:.2%}"
|
|
311
|
+
)
|
|
312
|
+
logger.info(f"Novel Chapter count: {len(novel.chapters)}")
|
|
313
|
+
logger.info(f"Dumping novel {novel.title} to {path}")
|
|
314
|
+
|
|
315
|
+
builder = (
|
|
316
|
+
NovelBuilder()
|
|
317
|
+
.new_novel()
|
|
318
|
+
.set_title(novel.title)
|
|
319
|
+
.set_description(novel.synopsis)
|
|
320
|
+
.add_css("p { text-indent: 2em; margin: 1em 0; line-height: 1.5; text-align: justify; }")
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
if self.novel_font_file:
|
|
324
|
+
(
|
|
325
|
+
builder.add_font(self.novel_font_file.stem, self.novel_font_file).add_css(
|
|
326
|
+
f"p {{ font-family: '{self.novel_font_file.stem}', 'sans-serif'; }}"
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
if self.cover_image:
|
|
331
|
+
builder.add_cover_image(self.cover_image.name, self.cover_image)
|
|
332
|
+
|
|
333
|
+
for chapter in novel.chapters:
|
|
334
|
+
builder.add_chapter(chapter.title, chapter.to_xhtml())
|
|
335
|
+
|
|
336
|
+
builder.export(path)
|
|
337
|
+
return path
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Capabilities defined in fabricatio-novel."""
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""This module contains the capabilities for the novel."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC
|
|
4
|
+
from typing import List, Optional, Unpack
|
|
5
|
+
|
|
6
|
+
from fabricatio_character.capabilities.character import CharacterCompose
|
|
7
|
+
from fabricatio_character.models.character import CharacterCard
|
|
8
|
+
from fabricatio_character.utils import dump_card
|
|
9
|
+
from fabricatio_core import TEMPLATE_MANAGER, logger
|
|
10
|
+
from fabricatio_core.capabilities.propose import Propose
|
|
11
|
+
from fabricatio_core.capabilities.usages import UseLLM
|
|
12
|
+
from fabricatio_core.models.kwargs_types import ValidateKwargs
|
|
13
|
+
from fabricatio_core.rust import detect_language
|
|
14
|
+
from fabricatio_core.utils import ok, override_kwargs
|
|
15
|
+
|
|
16
|
+
from fabricatio_novel.config import novel_config
|
|
17
|
+
from fabricatio_novel.models.novel import Chapter, Novel, NovelDraft
|
|
18
|
+
from fabricatio_novel.models.scripting import Script
|
|
19
|
+
from fabricatio_novel.rust import text_to_xhtml_paragraphs
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NovelCompose(CharacterCompose, Propose, UseLLM, ABC):
|
|
23
|
+
"""This class contains the capabilities for the novel."""
|
|
24
|
+
|
|
25
|
+
async def compose_novel(
|
|
26
|
+
self,
|
|
27
|
+
outline: str,
|
|
28
|
+
language: Optional[str] = None,
|
|
29
|
+
chapter_guidance: Optional[str] = None,
|
|
30
|
+
**kwargs: Unpack[ValidateKwargs[Novel]],
|
|
31
|
+
) -> Novel | None:
|
|
32
|
+
"""Main novel composition pipeline."""
|
|
33
|
+
logger.info(f"Starting novel generation for outline: {outline[:100]}...")
|
|
34
|
+
okwargs = override_kwargs(kwargs, default=None)
|
|
35
|
+
|
|
36
|
+
# Step 1: Generate draft
|
|
37
|
+
logger.debug("Step 1: Generating novel draft from outline")
|
|
38
|
+
draft = ok(await self.create_draft(outline, language, **okwargs))
|
|
39
|
+
if not draft:
|
|
40
|
+
logger.warn("Failed to generate novel draft.")
|
|
41
|
+
return None
|
|
42
|
+
logger.info(f"Draft generated successfully: '{draft.title}' in {draft.language}")
|
|
43
|
+
|
|
44
|
+
# Step 2: Generate characters
|
|
45
|
+
logger.debug("Step 2: Generating character cards from draft")
|
|
46
|
+
characters: List[CharacterCard] = [
|
|
47
|
+
c for c in ok(await self.create_characters(draft, **okwargs)) if c is not None
|
|
48
|
+
]
|
|
49
|
+
logger.info(f"Generated {len(characters)} valid character(s)")
|
|
50
|
+
|
|
51
|
+
# Step 3: Generate scripts
|
|
52
|
+
logger.debug("Step 3: Generating chapter scripts using draft and characters")
|
|
53
|
+
scripts = ok(await self.create_scripts(draft, characters, **okwargs))
|
|
54
|
+
clean_scripts = [s for s in scripts if s is not None]
|
|
55
|
+
if not clean_scripts:
|
|
56
|
+
logger.warn("No valid scripts were generated from the draft and characters.")
|
|
57
|
+
return None
|
|
58
|
+
logger.info(f"Successfully generated {len(clean_scripts)} script(s) for chapters")
|
|
59
|
+
|
|
60
|
+
# Step 4: Generate chapter contents
|
|
61
|
+
logger.debug("Step 4: Generating full chapter contents from scripts")
|
|
62
|
+
chapter_contents = await self.create_chapters(draft, clean_scripts, characters, chapter_guidance, **okwargs)
|
|
63
|
+
if not chapter_contents:
|
|
64
|
+
logger.warn("Chapter content generation returned no results.")
|
|
65
|
+
return None
|
|
66
|
+
logger.info(f"Generated {len(chapter_contents)} chapter content(s)")
|
|
67
|
+
|
|
68
|
+
# Step 5: Assemble final novel
|
|
69
|
+
logger.debug("Step 5: Assembling final novel from components")
|
|
70
|
+
novel = self.assemble_novel(draft, clean_scripts, chapter_contents)
|
|
71
|
+
logger.info(f"Novel assembly complete: '{novel.title}', {len(novel.chapters)} chapters")
|
|
72
|
+
return novel
|
|
73
|
+
|
|
74
|
+
async def create_draft(
|
|
75
|
+
self, outline: str, language: Optional[str] = None, **kwargs: Unpack[ValidateKwargs[NovelDraft]]
|
|
76
|
+
) -> NovelDraft | None:
|
|
77
|
+
"""Generate a draft for the novel based on the provided outline."""
|
|
78
|
+
logger.debug(f"Creating draft with outline: {outline[:200]}...")
|
|
79
|
+
detected_language = language or detect_language(outline)
|
|
80
|
+
logger.debug(f"Detected language: {detected_language}")
|
|
81
|
+
|
|
82
|
+
prompt = TEMPLATE_MANAGER.render_template(
|
|
83
|
+
novel_config.novel_draft_requirement_template,
|
|
84
|
+
{"outline": outline, "language": detected_language},
|
|
85
|
+
)
|
|
86
|
+
logger.debug(f"Rendered draft prompt:\n{prompt}")
|
|
87
|
+
|
|
88
|
+
result = await self.propose(NovelDraft, prompt, **kwargs)
|
|
89
|
+
if result:
|
|
90
|
+
logger.info(f"Draft created successfully: '{result.title}' ({result.expected_word_count} words)")
|
|
91
|
+
else:
|
|
92
|
+
logger.warn("Draft generation returned None.")
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
async def create_characters(
|
|
96
|
+
self, draft: NovelDraft, **kwargs: Unpack[ValidateKwargs[CharacterCard]]
|
|
97
|
+
) -> None | List[CharacterCard] | List[CharacterCard | None]:
|
|
98
|
+
"""Generate characters based on draft."""
|
|
99
|
+
logger.debug(f"Generating characters for novel: '{draft.title}'")
|
|
100
|
+
if not draft.character_descriptions:
|
|
101
|
+
logger.warn("No character descriptions found in draft.")
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
character_prompts = [
|
|
105
|
+
{
|
|
106
|
+
"novel_title": draft.title,
|
|
107
|
+
"synopsis": draft.synopsis,
|
|
108
|
+
"character_desc": c,
|
|
109
|
+
"language": draft.language,
|
|
110
|
+
}
|
|
111
|
+
for c in draft.character_descriptions
|
|
112
|
+
]
|
|
113
|
+
logger.debug(f"Prepared {len(character_prompts)} character prompts")
|
|
114
|
+
|
|
115
|
+
character_requirement = TEMPLATE_MANAGER.render_template(
|
|
116
|
+
novel_config.character_requirement_template, character_prompts
|
|
117
|
+
)
|
|
118
|
+
logger.debug(f"Character requirement template rendered (length: {len(character_requirement)})")
|
|
119
|
+
|
|
120
|
+
result = await self.compose_characters(character_requirement, **kwargs)
|
|
121
|
+
valid_chars = [c for c in (ok(result) or []) if c is not None]
|
|
122
|
+
logger.info(f"Generated {len(valid_chars)} valid character(s) out of {len(result or [])}")
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
async def create_scripts(
|
|
126
|
+
self, draft: NovelDraft, characters: List[CharacterCard], **kwargs: Unpack[ValidateKwargs[Script]]
|
|
127
|
+
) -> List[Script] | List[Script | None] | None:
|
|
128
|
+
"""Generate chapter scripts based on draft and characters."""
|
|
129
|
+
logger.debug(f"Generating {len(draft.chapter_synopses)} chapter scripts for '{draft.title}'")
|
|
130
|
+
if not characters:
|
|
131
|
+
logger.warn("No characters provided for script generation.")
|
|
132
|
+
return []
|
|
133
|
+
if not draft.chapter_synopses:
|
|
134
|
+
logger.warn("No chapter synopses in draft.")
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
character_prompt = dump_card(*characters)
|
|
138
|
+
logger.debug(f"Serialized {len(characters)} character(s) into prompt format")
|
|
139
|
+
|
|
140
|
+
script_prompts = [
|
|
141
|
+
{
|
|
142
|
+
"novel_title": draft.title,
|
|
143
|
+
"characters": character_prompt,
|
|
144
|
+
"synopsis": s,
|
|
145
|
+
"language": draft.language,
|
|
146
|
+
"expected_word_count": c,
|
|
147
|
+
}
|
|
148
|
+
for (s, c) in zip(draft.chapter_synopses, draft.chapter_expected_word_counts, strict=False)
|
|
149
|
+
]
|
|
150
|
+
logger.debug(f"Created {len(script_prompts)} script input prompts")
|
|
151
|
+
|
|
152
|
+
script_requirement = TEMPLATE_MANAGER.render_template(novel_config.script_requirement_template, script_prompts)
|
|
153
|
+
logger.debug(f"Script requirement template rendered (length: {len(script_requirement)})")
|
|
154
|
+
|
|
155
|
+
result = await self.propose(Script, script_requirement, **kwargs)
|
|
156
|
+
if result is None:
|
|
157
|
+
logger.warn("Script proposal returned None.")
|
|
158
|
+
else:
|
|
159
|
+
valid_scripts = [s for s in result if s is not None]
|
|
160
|
+
logger.info(f"Generated {len(valid_scripts)} valid script(s) out of {len(result)}")
|
|
161
|
+
return result
|
|
162
|
+
|
|
163
|
+
async def create_chapters(
|
|
164
|
+
self,
|
|
165
|
+
draft: NovelDraft,
|
|
166
|
+
scripts: List[Script],
|
|
167
|
+
characters: List[CharacterCard],
|
|
168
|
+
guidance: Optional[str] = None,
|
|
169
|
+
**kwargs: Unpack[ValidateKwargs[str]],
|
|
170
|
+
) -> List[str] | List[str | None]:
|
|
171
|
+
"""Generate actual chapter contents from scripts."""
|
|
172
|
+
logger.debug(f"Generating chapter contents for {len(scripts)} script(s)")
|
|
173
|
+
if not scripts:
|
|
174
|
+
logger.warn("No scripts provided for chapter generation.")
|
|
175
|
+
return []
|
|
176
|
+
|
|
177
|
+
character_prompt = dump_card(*characters)
|
|
178
|
+
logger.debug(f"Using {len(characters)} character(s) context for chapter generation")
|
|
179
|
+
|
|
180
|
+
chapter_prompts = [
|
|
181
|
+
{
|
|
182
|
+
"script": s.as_prompt(),
|
|
183
|
+
"characters": character_prompt,
|
|
184
|
+
"language": draft.language,
|
|
185
|
+
"guidance": guidance,
|
|
186
|
+
"expected_word_count": s.expected_word_count,
|
|
187
|
+
}
|
|
188
|
+
for s in scripts
|
|
189
|
+
]
|
|
190
|
+
logger.debug(f"Prepared {len(chapter_prompts)} chapter generation prompts")
|
|
191
|
+
|
|
192
|
+
chapter_requirement: List[str] = TEMPLATE_MANAGER.render_template(
|
|
193
|
+
novel_config.chapter_requirement_template, chapter_prompts
|
|
194
|
+
)
|
|
195
|
+
logger.debug(f"Chapter requirement template length: {len(chapter_requirement)}")
|
|
196
|
+
|
|
197
|
+
response = ok(await self.aask(chapter_requirement, **kwargs))
|
|
198
|
+
|
|
199
|
+
logger.info(f"Generated {len(response)} chapter content(s)")
|
|
200
|
+
return response
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def assemble_novel(draft: NovelDraft, scripts: List[Script], chapter_contents: List[str]) -> Novel:
|
|
204
|
+
"""Assemble the final novel from components."""
|
|
205
|
+
logger.debug("Assembling final novel from draft, scripts, and chapter contents")
|
|
206
|
+
if len(chapter_contents) != len(scripts):
|
|
207
|
+
logger.warn(
|
|
208
|
+
f"Mismatch between number of scripts ({len(scripts)}) and chapter contents ({len(chapter_contents)})"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
chapters = []
|
|
212
|
+
for i, (content, script) in enumerate(zip(chapter_contents, scripts, strict=False)):
|
|
213
|
+
title = script.title or f"Chapter {i + 1}"
|
|
214
|
+
cleaned_content = text_to_xhtml_paragraphs(content)
|
|
215
|
+
chapters.append(Chapter(title=title, content=cleaned_content, expected_word_count=0))
|
|
216
|
+
logger.info(f"Assembled {len(chapters)} chapter(s) into the final novel structure")
|
|
217
|
+
|
|
218
|
+
novel = Novel(
|
|
219
|
+
title=draft.title,
|
|
220
|
+
chapters=chapters,
|
|
221
|
+
synopsis=draft.synopsis,
|
|
222
|
+
expected_word_count=draft.expected_word_count,
|
|
223
|
+
)
|
|
224
|
+
logger.debug(f"Final novel assembled: '{novel.title}', total chapters: {len(novel.chapters)}")
|
|
225
|
+
return novel
|
fabricatio_novel/cli.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Fabricatio Novel CLI.
|
|
2
|
+
|
|
3
|
+
This module provides a command-line interface to generate novels using AI-driven workflows.
|
|
4
|
+
It utilizes the Fabricatio Core library and includes functionality for generating novels
|
|
5
|
+
with customizable outlines, chapter guidance, language options, styling, and more.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import NoReturn
|
|
10
|
+
|
|
11
|
+
from fabricatio_core.utils import cfg
|
|
12
|
+
|
|
13
|
+
cfg("fabricatio_novel.workflows", "questionary", "typer", feats=["cli"])
|
|
14
|
+
import typer
|
|
15
|
+
from fabricatio_core import Event, Role, Task
|
|
16
|
+
|
|
17
|
+
from fabricatio_novel.workflows.novel import DebugNovelWorkflow
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(help="A CLI tool to generate novels using AI-driven workflows.")
|
|
20
|
+
|
|
21
|
+
# Register the writer role and workflow
|
|
22
|
+
writer_role = (
|
|
23
|
+
Role(name="writer").register_workflow(Event.quick_instantiate(ns := "write"), DebugNovelWorkflow).dispatch()
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _exit_on_error(message: str) -> NoReturn:
|
|
28
|
+
"""Helper to display error and exit."""
|
|
29
|
+
typer.secho(message, fg=typer.colors.RED, bold=True)
|
|
30
|
+
raise typer.Exit(code=1) from None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command(name="w")
|
|
34
|
+
def write_novel( # noqa: PLR0913
|
|
35
|
+
outline: str = typer.Option(
|
|
36
|
+
None, "--outline", "-o", help="The novel's outline or premise.", envvar="NOVEL_OUTLINE"
|
|
37
|
+
),
|
|
38
|
+
outline_file: Path = typer.Option(
|
|
39
|
+
None,
|
|
40
|
+
"--outline-file",
|
|
41
|
+
"-of",
|
|
42
|
+
exists=True,
|
|
43
|
+
file_okay=True,
|
|
44
|
+
dir_okay=False,
|
|
45
|
+
resolve_path=True,
|
|
46
|
+
help="Path to a text file containing the novel outline.",
|
|
47
|
+
envvar="NOVEL_OUTLINE_FILE",
|
|
48
|
+
),
|
|
49
|
+
output_path: Path = typer.Option(
|
|
50
|
+
"./novel.epub", "--output", "-out", dir_okay=False, help="Output EPUB file path.", envvar="NOVEL_OUTPUT_PATH"
|
|
51
|
+
),
|
|
52
|
+
font_file: Path = typer.Option(
|
|
53
|
+
None,
|
|
54
|
+
"--font",
|
|
55
|
+
"-f",
|
|
56
|
+
exists=True,
|
|
57
|
+
dir_okay=False,
|
|
58
|
+
help="Path to custom font file (TTF).",
|
|
59
|
+
envvar="NOVEL_FONT_FILE",
|
|
60
|
+
),
|
|
61
|
+
cover_image: Path = typer.Option(
|
|
62
|
+
None,
|
|
63
|
+
"--cover",
|
|
64
|
+
"-c",
|
|
65
|
+
exists=True,
|
|
66
|
+
dir_okay=False,
|
|
67
|
+
help="Path to cover image (PNG/JPG/WEBP).",
|
|
68
|
+
envvar="NOVEL_COVER_IMAGE",
|
|
69
|
+
),
|
|
70
|
+
language: str = typer.Option(
|
|
71
|
+
"English", "--lang", "-l", help="Language of the novel (e.g., 简体中文, English, jp).", envvar="NOVEL_LANGUAGE"
|
|
72
|
+
),
|
|
73
|
+
chapter_guidance: str = typer.Option(
|
|
74
|
+
None, "--guidance", "-g", help="Guidelines for chapter generation.", envvar="NOVEL_CHAPTER_GUIDANCE"
|
|
75
|
+
),
|
|
76
|
+
guidance_file: Path = typer.Option(
|
|
77
|
+
None,
|
|
78
|
+
"--guidance-file",
|
|
79
|
+
"-gf",
|
|
80
|
+
exists=True,
|
|
81
|
+
file_okay=True,
|
|
82
|
+
dir_okay=False,
|
|
83
|
+
resolve_path=True,
|
|
84
|
+
help="Path to a text file containing chapter generation guidelines.",
|
|
85
|
+
envvar="NOVEL_GUIDANCE_FILE",
|
|
86
|
+
),
|
|
87
|
+
persist_dir: Path = typer.Option(
|
|
88
|
+
"./persist", "--persist-dir", help="Directory to save intermediate states.", envvar="NOVEL_PERSIST_DIR"
|
|
89
|
+
),
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Generate a novel based on the provided outline and settings."""
|
|
92
|
+
# Check mutual exclusivity for outline
|
|
93
|
+
if outline is not None and outline_file is not None:
|
|
94
|
+
_exit_on_error("❌ Cannot use both --outline and --outline-file. Please use only one.")
|
|
95
|
+
|
|
96
|
+
if outline is None and outline_file is None:
|
|
97
|
+
_exit_on_error("❌ Either --outline or --outline-file must be provided.")
|
|
98
|
+
|
|
99
|
+
# Read outline
|
|
100
|
+
try:
|
|
101
|
+
outline_content = outline_file.read_text(encoding="utf-8").strip() if outline_file else outline.strip()
|
|
102
|
+
except (OSError, IOError) as e:
|
|
103
|
+
_exit_on_error(f"❌ Failed to read outline file: {e}")
|
|
104
|
+
|
|
105
|
+
# Check mutual exclusivity for guidance
|
|
106
|
+
if chapter_guidance is not None and guidance_file is not None:
|
|
107
|
+
_exit_on_error("❌ Cannot use both --guidance and --guidance-file. Please use only one.")
|
|
108
|
+
|
|
109
|
+
# Read guidance
|
|
110
|
+
try:
|
|
111
|
+
if guidance_file:
|
|
112
|
+
guidance_content = guidance_file.read_text(encoding="utf-8").strip()
|
|
113
|
+
elif chapter_guidance is not None:
|
|
114
|
+
guidance_content = chapter_guidance.strip()
|
|
115
|
+
else:
|
|
116
|
+
guidance_content = ""
|
|
117
|
+
except (OSError, IOError) as e:
|
|
118
|
+
_exit_on_error(f"❌ Failed to read guidance file: {e}")
|
|
119
|
+
|
|
120
|
+
typer.echo(f"Starting novel generation: '{outline_content[:30]}...'")
|
|
121
|
+
|
|
122
|
+
task = Task(name="Write novel").update_init_context(
|
|
123
|
+
novel_outline=outline_content,
|
|
124
|
+
output_path=output_path,
|
|
125
|
+
novel_font_file=font_file,
|
|
126
|
+
cover_image=cover_image,
|
|
127
|
+
novel_language=language,
|
|
128
|
+
chapter_guidance=guidance_content,
|
|
129
|
+
persist_dir=persist_dir,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
result = task.delegate_blocking(ns)
|
|
133
|
+
|
|
134
|
+
if result:
|
|
135
|
+
typer.secho(f"✅ Novel successfully generated: {result}", fg=typer.colors.GREEN, bold=True)
|
|
136
|
+
else:
|
|
137
|
+
_exit_on_error("❌ Failed to generate novel.")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command()
|
|
141
|
+
def info() -> None:
|
|
142
|
+
"""Show information about this CLI tool."""
|
|
143
|
+
typer.echo("📘 Fabricatio Novel Generator CLI")
|
|
144
|
+
typer.echo("Generate AI-assisted novels in various languages with customizable styling.")
|
|
145
|
+
typer.echo("Powered by Fabricatio Core & DebugNovelWorkflow.")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
__all__ = ["app"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Module containing configuration classes for fabricatio-novel."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from fabricatio_core import CONFIG
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class NovelConfig:
|
|
10
|
+
"""Configuration for fabricatio-novel."""
|
|
11
|
+
|
|
12
|
+
render_script_template: str = "render_script"
|
|
13
|
+
"""template used to render scripts."""
|
|
14
|
+
|
|
15
|
+
character_requirement_template: str = "character_requirement"
|
|
16
|
+
"""template used to render character requirements."""
|
|
17
|
+
script_requirement_template: str = "script_requirement"
|
|
18
|
+
"""template used to render script requirements."""
|
|
19
|
+
chapter_requirement_template: str = "chapter_requirement"
|
|
20
|
+
"""template used to render chapter requirements."""
|
|
21
|
+
render_chapter_xhtml_template: str = "render_chapter_xhtml"
|
|
22
|
+
"""template used to render chapter xhtml."""
|
|
23
|
+
novel_draft_requirement_template: str = "novel_draft_requirement"
|
|
24
|
+
"""template used to render novel draft requirements."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
novel_config = CONFIG.load("novel", NovelConfig)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = ["novel_config"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Models defined in fabricatio-novel."""
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""This module contains the models for the novel."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, List
|
|
4
|
+
|
|
5
|
+
from fabricatio_capabilities.models.generic import PersistentAble, WordCount
|
|
6
|
+
from fabricatio_core import TEMPLATE_MANAGER
|
|
7
|
+
from fabricatio_core.models.generic import Language, SketchedAble, Titled
|
|
8
|
+
from fabricatio_core.rust import logger, word_count
|
|
9
|
+
|
|
10
|
+
from fabricatio_novel.config import novel_config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NovelDraft(SketchedAble, Titled, Language, PersistentAble, WordCount):
|
|
14
|
+
"""A draft representing a novel, including its title, genre, characters, chapters, and synopsis."""
|
|
15
|
+
|
|
16
|
+
title: str
|
|
17
|
+
"""The title of the novel."""
|
|
18
|
+
genre: List[str]
|
|
19
|
+
"""The genres of the novel. Comprehensive coverage is preferred than few ones."""
|
|
20
|
+
|
|
21
|
+
synopsis: str
|
|
22
|
+
"""A summary of the novel's plot."""
|
|
23
|
+
|
|
24
|
+
character_descriptions: List[str]
|
|
25
|
+
"""
|
|
26
|
+
Every string in this list MUST be at least 180 words.
|
|
27
|
+
Super detailed descriptions for each main character.
|
|
28
|
+
Include: looks, personality, backstory, goals, relationships, inner struggles, and their role in the story.
|
|
29
|
+
Goal: Make every character feel real, consistent, and fully fleshed out — no vague or shallow summaries.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
chapter_synopses: List[str]
|
|
33
|
+
"""
|
|
34
|
+
Every string in this list MUST be at least 270 words.
|
|
35
|
+
Super detailed summaries for each chapter.
|
|
36
|
+
Cover: what happens, how characters change, key scenes/dialogue, setting shifts, emotional tone, and hints or themes.
|
|
37
|
+
Goal: Lock in every important detail so nothing gets lost later — like a mini-script for each chapter.
|
|
38
|
+
"""
|
|
39
|
+
expected_word_count: int
|
|
40
|
+
"""The expected word count of the novel."""
|
|
41
|
+
|
|
42
|
+
chapter_expected_word_counts: List[int]
|
|
43
|
+
"""List of expected word counts for each chapter in the novel. should be the same length as chapter_synopses."""
|
|
44
|
+
|
|
45
|
+
def model_post_init(self, context: Any, /) -> None:
|
|
46
|
+
"""Make sure that the chapter expected word counts are aligned with the chapter synopses."""
|
|
47
|
+
if len(self.chapter_synopses) != len(self.chapter_expected_word_counts):
|
|
48
|
+
if self.chapter_expected_word_counts:
|
|
49
|
+
logger.warn(
|
|
50
|
+
"Chapter expected word counts are not aligned with chapter synopses, using the last valid one to fill the rest."
|
|
51
|
+
)
|
|
52
|
+
# If word counts are not aligned, copy the last valid chapter's word count
|
|
53
|
+
last_valid_wc = self.chapter_expected_word_counts[-1]
|
|
54
|
+
self.chapter_expected_word_counts.extend(
|
|
55
|
+
[last_valid_wc] * (len(self.chapter_synopses) - len(self.chapter_expected_word_counts))
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
58
|
+
logger.warn("No chapter expected word counts provided, using the expected word count to fill the list.")
|
|
59
|
+
# If the word count list is totally empty, distribute the expected word count evenly
|
|
60
|
+
avg_wc = self.expected_word_count // len(self.chapter_synopses)
|
|
61
|
+
self.chapter_expected_word_counts = [avg_wc] * len(self.chapter_synopses)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Chapter(SketchedAble, PersistentAble, Titled, WordCount):
|
|
65
|
+
"""A chapter in a novel."""
|
|
66
|
+
|
|
67
|
+
content: str
|
|
68
|
+
"""The content of the chapter."""
|
|
69
|
+
|
|
70
|
+
def to_xhtml(self) -> str:
|
|
71
|
+
"""Convert the chapter to XHTML format."""
|
|
72
|
+
return TEMPLATE_MANAGER.render_template(novel_config.render_chapter_xhtml_template, self.model_dump())
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def exact_word_count(self) -> int:
|
|
76
|
+
"""Calculate the exact word count of the chapter."""
|
|
77
|
+
return word_count(self.content)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Novel(SketchedAble, PersistentAble, Titled, WordCount):
|
|
81
|
+
"""A novel."""
|
|
82
|
+
|
|
83
|
+
synopsis: str
|
|
84
|
+
"""A summary of the novel's plot."""
|
|
85
|
+
chapters: List[Chapter]
|
|
86
|
+
"""List of chapters in the novel."""
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def exact_word_count(self) -> int:
|
|
90
|
+
"""Calculate the exact word count of the novel."""
|
|
91
|
+
return sum(chapter.exact_word_count for chapter in self.chapters)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def word_count_compliance_ratio(self) -> float:
|
|
95
|
+
"""Calculate the compliance ratio of the novel's word count."""
|
|
96
|
+
return self.exact_word_count / self.expected_word_count
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""This module defines the core data structures for narrative scenes and scripts.
|
|
2
|
+
|
|
3
|
+
Together, these classes form a foundation for creating structured yet flexible narrative content.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, ClassVar, Dict, List, Self
|
|
7
|
+
|
|
8
|
+
from fabricatio_capabilities.models.generic import AsPrompt, PersistentAble, WordCount
|
|
9
|
+
from fabricatio_core.models.generic import SketchedAble, Titled
|
|
10
|
+
from pydantic import Field
|
|
11
|
+
|
|
12
|
+
from fabricatio_novel.config import novel_config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Scene(PersistentAble, SketchedAble, WordCount):
|
|
16
|
+
"""The most basic narrative unit."""
|
|
17
|
+
|
|
18
|
+
expected_word_count: int
|
|
19
|
+
"""Expected word count when writing the content that the Scene is narrating."""
|
|
20
|
+
|
|
21
|
+
tags: List[str]
|
|
22
|
+
"""free-form semantic labels for filtering, grouping, or post-processing."""
|
|
23
|
+
|
|
24
|
+
prompt: str
|
|
25
|
+
"""natural language guidance for tone, style, or constraint."""
|
|
26
|
+
|
|
27
|
+
narrative: str
|
|
28
|
+
"""dialogue, description, log, poem, monologue, etc."""
|
|
29
|
+
|
|
30
|
+
def append_prompt(self, prompt: str) -> Self:
|
|
31
|
+
"""Add a prompt to the scene.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
prompt (str): The prompt to add.
|
|
35
|
+
"""
|
|
36
|
+
self.prompt += f"\n{prompt}"
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Script(SketchedAble, PersistentAble, Titled, AsPrompt, WordCount):
|
|
41
|
+
"""A sequence of scenes forming a cohesive narrative unit especially for a novel chapter."""
|
|
42
|
+
|
|
43
|
+
title: str = Field(examples=["Ch1: A Chapter Title For Example", "Ch1: 一个示例章节标题"])
|
|
44
|
+
"""Title of the chapter."""
|
|
45
|
+
|
|
46
|
+
expected_word_count: int
|
|
47
|
+
"""Expected word count for this chapter."""
|
|
48
|
+
|
|
49
|
+
global_prompt: str
|
|
50
|
+
"""global writing guidance applied to all scenes."""
|
|
51
|
+
|
|
52
|
+
scenes: List[Scene]
|
|
53
|
+
"""Ordered list of scenes. Must contain at least one scene. Sequence implies narrative flow."""
|
|
54
|
+
|
|
55
|
+
rendering_template: ClassVar[str] = novel_config.render_script_template
|
|
56
|
+
|
|
57
|
+
def _as_prompt_inner(self) -> Dict[str, str] | Dict[str, Any] | Any:
|
|
58
|
+
return self.model_dump()
|
|
59
|
+
|
|
60
|
+
def append_global_prompt(self, prompt: str) -> Self:
|
|
61
|
+
"""Add a global prompt to the script.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
prompt (str): The global prompt to add.
|
|
65
|
+
"""
|
|
66
|
+
self.global_prompt += f"\n{prompt}"
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def set_expected_word_count(self, word_count: int) -> Self:
|
|
70
|
+
"""Set the expected word count for the script.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
word_count (int): The expected word count.
|
|
74
|
+
"""
|
|
75
|
+
self.expected_word_count = word_count
|
|
76
|
+
return self
|
|
File without changes
|
|
Binary file
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Rust bindings for the Rust API of fabricatio-novel."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Self
|
|
5
|
+
|
|
6
|
+
class NovelBuilder:
|
|
7
|
+
"""A Python-exposed builder for creating EPUB novels."""
|
|
8
|
+
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
"""Creates a new uninitialized NovelBuilder instance."""
|
|
11
|
+
|
|
12
|
+
def new_novel(self) -> Self:
|
|
13
|
+
"""Initializes a new EPUB novel builder.
|
|
14
|
+
|
|
15
|
+
Raises:
|
|
16
|
+
RuntimeError: If initialization fails.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def set_title(self, title: str) -> Self:
|
|
20
|
+
"""Sets the novel title.
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
RuntimeError: If novel is not initialized.
|
|
24
|
+
"""
|
|
25
|
+
def add_author(self, author: str) -> Self:
|
|
26
|
+
"""Adds an author to the novel metadata.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
RuntimeError: If novel is not initialized.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def add_chapter(self, title: str, content: str) -> Self:
|
|
33
|
+
"""Adds a chapter with given title and content.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
RuntimeError: If novel is not initialized or chapter creation fails.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def set_description(self, description: str) -> Self:
|
|
40
|
+
"""Sets the novel description.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
RuntimeError: If novel is not initialized.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def add_cover_image(self, path: str | Path, source: str | Path) -> Self:
|
|
47
|
+
"""Adds a cover image from the given file path.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
path: Path inside EPUB where image will be stored (e.g., "cover.png").
|
|
51
|
+
source: Filesystem path to the image file to read.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
RuntimeError: If novel not initialized, file read fails, or adding image fails.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def add_metadata(self, key: str, value: str) -> Self:
|
|
58
|
+
"""Adds custom metadata key-value pair to the novel.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
RuntimeError: If novel is not initialized or metadata is invalid.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def add_css(self, css: str) -> Self:
|
|
65
|
+
"""Adds custom CSS to the novel.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
css: A string containing the CSS content to be added.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
RuntimeError: If novel is not initialized or CSS addition fails.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def add_resource(self, path: str | Path, source: str | Path) -> Self:
|
|
75
|
+
"""Add a resource to the EPUB.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
path: Internal EPUB path (e.g. 'images/cover.jpg').
|
|
79
|
+
source: Filesystem path to read from.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Self for chaining.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def add_font(self, font_family: str, source: str | Path) -> Self:
|
|
86
|
+
"""Embed a font and add @font-face CSS rule.
|
|
87
|
+
|
|
88
|
+
Font saved as 'fonts/{font_family}.ttf'.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
font_family: Name used in CSS and filename.
|
|
92
|
+
source: TTF font file on disk.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Self for chaining.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def add_inline_toc(self) -> Self:
|
|
99
|
+
"""Enables inline table of contents generation.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
RuntimeError: If novel is not initialized.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def export(self, path: str | Path) -> Self:
|
|
106
|
+
"""Exports the built novel to the specified file path.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
RuntimeError: If novel not initialized, generation fails, or file write fails.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def text_to_xhtml_paragraphs(source: str) -> str:
|
|
113
|
+
"""Converts plain text to XHTML paragraphs.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
source: Plain text to convert.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
XHTML string with paragraphs.
|
|
120
|
+
"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Workflows defined in fabricatio-novel."""
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""This module defines various workflows for novel generation, from full pipelines to granular steps.
|
|
2
|
+
|
|
3
|
+
Each workflow is designed for specific use cases: full generation, debugging, component regeneration, validation, etc.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from fabricatio_core.utils import cfg
|
|
7
|
+
|
|
8
|
+
cfg("fabricatio_actions", feats=["workflows"])
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from fabricatio_actions.actions.output import PersistentAll
|
|
12
|
+
from fabricatio_core import WorkFlow
|
|
13
|
+
|
|
14
|
+
from fabricatio_novel.actions.novel import (
|
|
15
|
+
AssembleNovelFromComponents,
|
|
16
|
+
DumpNovel,
|
|
17
|
+
GenerateChaptersFromScripts,
|
|
18
|
+
GenerateCharactersFromDraft,
|
|
19
|
+
GenerateNovel, # One-step full pipeline
|
|
20
|
+
GenerateNovelDraft,
|
|
21
|
+
GenerateScriptsFromDraftAndCharacters,
|
|
22
|
+
ValidateNovel,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# ==============================
|
|
26
|
+
# 🚀 One-Step Full Novel Generation (Existing, standardized)
|
|
27
|
+
# ==============================
|
|
28
|
+
WriteNovelWorkflow = WorkFlow(
|
|
29
|
+
name="WriteNovelWorkflow",
|
|
30
|
+
description="Generate and dump a novel from outline in one go.",
|
|
31
|
+
steps=(GenerateNovel, DumpNovel().to_task_output(), PersistentAll),
|
|
32
|
+
)
|
|
33
|
+
"""Generate a novel from outline and dump it to file."""
|
|
34
|
+
|
|
35
|
+
# ==============================
|
|
36
|
+
# 🧩 Step-by-Step Debug Workflow (Recommended for development)
|
|
37
|
+
# ==============================
|
|
38
|
+
DebugNovelWorkflow = WorkFlow(
|
|
39
|
+
name="DebugNovelWorkflow",
|
|
40
|
+
description="Step-by-step novel generation for inspection and debugging.",
|
|
41
|
+
steps=(
|
|
42
|
+
GenerateNovelDraft,
|
|
43
|
+
PersistentAll,
|
|
44
|
+
GenerateCharactersFromDraft,
|
|
45
|
+
PersistentAll,
|
|
46
|
+
GenerateScriptsFromDraftAndCharacters,
|
|
47
|
+
PersistentAll,
|
|
48
|
+
GenerateChaptersFromScripts,
|
|
49
|
+
PersistentAll,
|
|
50
|
+
AssembleNovelFromComponents,
|
|
51
|
+
DumpNovel().to_task_output(),
|
|
52
|
+
PersistentAll,
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
"""Use this workflow to debug each stage of novel generation."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ==============================
|
|
59
|
+
# 🎭 Generate Characters Only (For character design phase)
|
|
60
|
+
# ==============================
|
|
61
|
+
GenerateOnlyCharactersWorkflow = WorkFlow(
|
|
62
|
+
name="GenerateOnlyCharactersWorkflow",
|
|
63
|
+
description="Generate character cards from a given novel draft.",
|
|
64
|
+
steps=(
|
|
65
|
+
GenerateNovelDraft,
|
|
66
|
+
GenerateCharactersFromDraft,
|
|
67
|
+
PersistentAll,
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
"""Useful for iterating on character design before full generation."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ==============================
|
|
74
|
+
# 📖 Rewrite Chapters Only (Reuse scripts + characters → regenerate prose)
|
|
75
|
+
# ==============================
|
|
76
|
+
RewriteChaptersOnlyWorkflow = WorkFlow(
|
|
77
|
+
name="RewriteChaptersOnlyWorkflow",
|
|
78
|
+
description="Regenerate chapter contents from existing scripts and characters.",
|
|
79
|
+
steps=(
|
|
80
|
+
GenerateChaptersFromScripts, # expects draft, scripts, characters in context
|
|
81
|
+
AssembleNovelFromComponents,
|
|
82
|
+
DumpNovel().to_task_output(),
|
|
83
|
+
PersistentAll,
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
"""Use when you want to rewrite chapter prose without changing plot or characters."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ==============================
|
|
90
|
+
# ✅ Validated Full Pipeline (Production-grade with quality checks)
|
|
91
|
+
# ==============================
|
|
92
|
+
ValidatedNovelWorkflow = WorkFlow(
|
|
93
|
+
name="ValidatedNovelWorkflow",
|
|
94
|
+
description="Generate novel with post-generation validation for quality control.",
|
|
95
|
+
steps=(
|
|
96
|
+
GenerateNovel,
|
|
97
|
+
ValidateNovel, # Halts or warns if validation fails (depends on engine)
|
|
98
|
+
DumpNovel().to_task_output(),
|
|
99
|
+
PersistentAll,
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
"""Ideal for production: ensures minimum chapters, word count, and compliance ratio."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ==============================
|
|
106
|
+
# 🔄 Regenerate with New Characters (A/B test character impact)
|
|
107
|
+
# ==============================
|
|
108
|
+
RegenerateWithNewCharactersWorkflow = WorkFlow(
|
|
109
|
+
name="RegenerateWithNewCharactersWorkflow",
|
|
110
|
+
description="Reuse existing draft but regenerate story with new characters.",
|
|
111
|
+
steps=(
|
|
112
|
+
GenerateNovelDraft, # Or inject pre-existing draft via context
|
|
113
|
+
GenerateCharactersFromDraft, # May yield different characters
|
|
114
|
+
GenerateScriptsFromDraftAndCharacters,
|
|
115
|
+
GenerateChaptersFromScripts,
|
|
116
|
+
AssembleNovelFromComponents,
|
|
117
|
+
DumpNovel(output_path=Path("output_with_new_characters.epub")).to_task_output(),
|
|
118
|
+
PersistentAll,
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
"""Use to explore how different character sets affect narrative outcomes."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ==============================
|
|
125
|
+
# 💾 Dump Only Workflow (For pre-generated Novel objects)
|
|
126
|
+
# ==============================
|
|
127
|
+
DumpOnlyWorkflow = WorkFlow(
|
|
128
|
+
name="DumpOnlyWorkflow",
|
|
129
|
+
description="Only dump an existing Novel object to file (no generation).",
|
|
130
|
+
steps=(
|
|
131
|
+
DumpNovel,
|
|
132
|
+
PersistentAll,
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
"""Use when Novel is pre-generated or loaded from DB/cache."""
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fabricatio-novel
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
5
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
6
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
7
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
8
|
+
Classifier: Typing :: Typed
|
|
9
|
+
Requires-Dist: fabricatio-character
|
|
10
|
+
Requires-Dist: fabricatio-core
|
|
11
|
+
Requires-Dist: pydantic>=2.11.9
|
|
12
|
+
Requires-Dist: fabricatio-novel[workflows,cli] ; extra == 'full'
|
|
13
|
+
Requires-Dist: fabricatio-actions ; extra == 'workflows'
|
|
14
|
+
Requires-Dist: fabricatio-novel[workflows] ; extra == 'cli'
|
|
15
|
+
Requires-Dist: questionary>=2.1.0 ; extra == 'cli'
|
|
16
|
+
Requires-Dist: typer-slim[standard]>=0.15.2 ; extra == 'cli'
|
|
17
|
+
Provides-Extra: full
|
|
18
|
+
Provides-Extra: workflows
|
|
19
|
+
Provides-Extra: cli
|
|
20
|
+
Summary: An extension of fabricatio
|
|
21
|
+
Author-email: Whth <zettainspector@foxmail.com>
|
|
22
|
+
License-Expression: MIT
|
|
23
|
+
Requires-Python: >=3.12, <3.14
|
|
24
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
25
|
+
Project-URL: Homepage, https://github.com/Whth/fabricatio
|
|
26
|
+
Project-URL: Repository, https://github.com/Whth/fabricatio
|
|
27
|
+
Project-URL: Issues, https://github.com/Whth/fabricatio/issues
|
|
28
|
+
|
|
29
|
+
# `fabricatio-novel`
|
|
30
|
+
|
|
31
|
+
[MIT](https://img.shields.io/badge/license-MIT-blue.svg)
|
|
32
|
+

|
|
33
|
+
[](https://pypi.org/project/fabricatio-novel/)
|
|
34
|
+
[](https://pepy.tech/projects/fabricatio-novel)
|
|
35
|
+
[](https://pepy.tech/projects/fabricatio-novel)
|
|
36
|
+
[](https://github.com/PyO3/pyo3)
|
|
37
|
+
[](https://github.com/astral-sh/uv)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
An extension of fabricatio.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## 📦 Installation
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
This package is part of the `fabricatio` monorepo and can be installed as an optional dependency:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install fabricatio[novel]
|
|
52
|
+
|
|
53
|
+
# or with uv
|
|
54
|
+
# uv pip install fabricatio[novel]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or install `fabricatio-diff` along with all other components of `fabricatio`:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install fabricatio[full]
|
|
61
|
+
|
|
62
|
+
# or with uv
|
|
63
|
+
# uv pip install fabricatio[full]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## 🔍 Overview
|
|
67
|
+
|
|
68
|
+
Provides essential tools for:
|
|
69
|
+
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
## 🧩 Key Features
|
|
75
|
+
|
|
76
|
+
...
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
## 🔗 Dependencies
|
|
80
|
+
|
|
81
|
+
Core dependencies:
|
|
82
|
+
|
|
83
|
+
- `fabricatio-core` - Core interfaces and utilities
|
|
84
|
+
...
|
|
85
|
+
|
|
86
|
+
## 📄 License
|
|
87
|
+
|
|
88
|
+
This project is licensed under the MIT License.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
fabricatio_novel-0.1.1.dist-info/METADATA,sha256=MmTevCkKPL_vXQZmr7MnVcQQm6uLhFLTI_nCOpF4_Bk,2619
|
|
2
|
+
fabricatio_novel-0.1.1.dist-info/WHEEL,sha256=K7foeVF-x_RZTycPKa1uE1HH2bAWe3AiJbihrXn5Hhc,96
|
|
3
|
+
fabricatio_novel-0.1.1.dist-info/entry_points.txt,sha256=Yv1aMQSZeRVHkj1KGkwUrYmNBdIt0F0a_rCdW0KxUco,49
|
|
4
|
+
fabricatio_novel/__init__.py,sha256=gumbL2CwfKm4bnxOVpyhYxAIwfVldHTB30OCZBtxxMw,35
|
|
5
|
+
fabricatio_novel/actions/__init__.py,sha256=51yoYj8CX5mf8bRNlyWo_1_d_duCkWNmh1P85lQ1iow,44
|
|
6
|
+
fabricatio_novel/actions/novel.py,sha256=disJXRlzHVgiC3xjboiCLcKPGILIpjWLb0I3WejcsQ0,11528
|
|
7
|
+
fabricatio_novel/capabilities/__init__.py,sha256=uwDvkp2BAIL6VOM5xW4KXdesIqfgxpwXlfdzIlHLtt4,49
|
|
8
|
+
fabricatio_novel/capabilities/novel.py,sha256=5mw3TgB_1NdN_fryPz_fUocuu5NEVS6-56mChUr6gt0,10373
|
|
9
|
+
fabricatio_novel/cli.py,sha256=XCeoXaJnrIuWwHNGecRAz3bLuC1RDRhcRIeQUgxYZ_Y,5252
|
|
10
|
+
fabricatio_novel/config.py,sha256=AlJPrW7lXnQHd7V4rlwUV2sMd_rAdjHLpfRwvCu-OLg,1035
|
|
11
|
+
fabricatio_novel/models/__init__.py,sha256=olnoHX_uyVSsNniJibjBvdcKc70s1qMMULOG7dwWp0s,43
|
|
12
|
+
fabricatio_novel/models/novel.py,sha256=M3bKy1Yy_U-3xSFPkC9EzzlgODjKxSczHe2OH3zipHs,4224
|
|
13
|
+
fabricatio_novel/models/scripting.py,sha256=3fVf16m8nPHH5opb8XRhVzphUBG0ffXAoQnBslLYifQ,2529
|
|
14
|
+
fabricatio_novel/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
fabricatio_novel/rust.cp313-win_amd64.pyd,sha256=MD0KZQD5MfJ0mbheu95raK9uCxAwn6Up8uAhAFtG5y8,6124544
|
|
16
|
+
fabricatio_novel/rust.pyi,sha256=vEnCYqisFanxPoFPzHRqV87cg_WmNlzvwmMkOYRc1zw,3509
|
|
17
|
+
fabricatio_novel/workflows/__init__.py,sha256=G3uMeM4y-ZEoO7KwJ4ssMoCuxY8Hw0IjOvLBBwJdy3c,46
|
|
18
|
+
fabricatio_novel/workflows/novel.py,sha256=piRAhw_UVcMvdJY69E4BZVGo1Ok1kz94wQaucq5UNWw,4767
|
|
19
|
+
fabricatio_novel-0.1.1.dist-info/RECORD,,
|