novelforge 1.0__py3-none-any.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.
- novelforge/__init__.py +10 -0
- novelforge/__main__.py +6 -0
- novelforge/core/generators.py +129 -0
- novelforge/core/memory.py +166 -0
- novelforge/core/novel_manager.py +194 -0
- novelforge/core/prompts.py +156 -0
- novelforge/core/rag.py +60 -0
- novelforge/ui/app.py +449 -0
- novelforge/ui/chat_panel.py +260 -0
- novelforge/ui/dialogs.py +134 -0
- novelforge/ui/sidebar.py +222 -0
- novelforge/ui/style.py +363 -0
- novelforge/ui/widgets.py +304 -0
- novelforge/ui/workers.py +136 -0
- novelforge-1.0.dist-info/METADATA +269 -0
- novelforge-1.0.dist-info/RECORD +20 -0
- novelforge-1.0.dist-info/WHEEL +5 -0
- novelforge-1.0.dist-info/entry_points.txt +2 -0
- novelforge-1.0.dist-info/licenses/LICENSE +21 -0
- novelforge-1.0.dist-info/top_level.txt +1 -0
novelforge/__init__.py
ADDED
novelforge/__main__.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import ollama
|
|
6
|
+
|
|
7
|
+
from novelforge.core.prompts import (
|
|
8
|
+
NOVEL_SYSTEM_PROMPT,
|
|
9
|
+
CREATE_NOVEL_PROMPT,
|
|
10
|
+
CHAPTER_PLAN_PROMPT,
|
|
11
|
+
CONTINUE_NOVEL_PROMPT,
|
|
12
|
+
MEMORY_COMPRESSION_PROMPT,
|
|
13
|
+
CHARACTER_EXTRACTION_PROMPT,
|
|
14
|
+
LORE_EXTRACTION_PROMPT,
|
|
15
|
+
QA_PROMPT,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
STORY_MODEL = "qwen3:8b"
|
|
19
|
+
|
|
20
|
+
GPU_OPTIONS = {
|
|
21
|
+
"num_gpu": -1,
|
|
22
|
+
"main_gpu": 0,
|
|
23
|
+
"num_batch": 512,
|
|
24
|
+
"num_ctx": 4096,
|
|
25
|
+
"f16_kv": True,
|
|
26
|
+
"use_mmap": True,
|
|
27
|
+
"use_mlock": False,
|
|
28
|
+
"num_thread": 0,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def llm(
|
|
33
|
+
prompt: str,
|
|
34
|
+
system: str = NOVEL_SYSTEM_PROMPT,
|
|
35
|
+
temperature: float = 0.8,
|
|
36
|
+
) -> str:
|
|
37
|
+
response = ollama.chat(
|
|
38
|
+
model=STORY_MODEL,
|
|
39
|
+
messages=[
|
|
40
|
+
{"role": "system", "content": system},
|
|
41
|
+
{"role": "user", "content": prompt},
|
|
42
|
+
],
|
|
43
|
+
options={**GPU_OPTIONS, "temperature": temperature},
|
|
44
|
+
)
|
|
45
|
+
return response["message"]["content"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def classify_intent(user_input: str) -> str:
|
|
49
|
+
text = user_input.lower().strip()
|
|
50
|
+
new_keywords = [
|
|
51
|
+
"new novel",
|
|
52
|
+
"new story",
|
|
53
|
+
"write a novel",
|
|
54
|
+
"create a novel",
|
|
55
|
+
"start a novel",
|
|
56
|
+
"novel idea",
|
|
57
|
+
]
|
|
58
|
+
question_keywords = ["who", "what", "when", "where", "why", "how"]
|
|
59
|
+
if any(k in text for k in new_keywords):
|
|
60
|
+
return "NEW_NOVEL"
|
|
61
|
+
if "?" in text:
|
|
62
|
+
return "QUESTION"
|
|
63
|
+
if any(text.startswith(k) for k in question_keywords):
|
|
64
|
+
return "QUESTION"
|
|
65
|
+
return "CONTINUE"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def create_first_chapter(idea: str) -> str:
|
|
69
|
+
return llm(CREATE_NOVEL_PROMPT.format(idea=idea))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def create_chapter_plan(memory: str, instruction: str) -> str:
|
|
73
|
+
return llm(
|
|
74
|
+
CHAPTER_PLAN_PROMPT.format(memory=memory, instruction=instruction),
|
|
75
|
+
temperature=0.4,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def generate_chapter(memory: str, plan: str, instruction: str) -> str:
|
|
80
|
+
return llm(
|
|
81
|
+
CONTINUE_NOVEL_PROMPT.format(memory=memory, plan=plan, instruction=instruction)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def compress_memory(chapter: str) -> str:
|
|
86
|
+
return llm(
|
|
87
|
+
MEMORY_COMPRESSION_PROMPT.format(chapter=chapter),
|
|
88
|
+
temperature=0.2,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def extract_characters(chapter: str) -> list[dict]:
|
|
93
|
+
result = llm(
|
|
94
|
+
CHARACTER_EXTRACTION_PROMPT.format(chapter=chapter),
|
|
95
|
+
temperature=0.1,
|
|
96
|
+
)
|
|
97
|
+
try:
|
|
98
|
+
return json.loads(result)
|
|
99
|
+
except Exception:
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def extract_lore(chapter: str) -> str:
|
|
104
|
+
return llm(
|
|
105
|
+
LORE_EXTRACTION_PROMPT.format(chapter=chapter),
|
|
106
|
+
temperature=0.2,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def answer_story_question(context: str, question: str) -> str:
|
|
111
|
+
return llm(
|
|
112
|
+
QA_PROMPT.format(context=context, question=question),
|
|
113
|
+
temperature=0.1,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def generate_story_package(memory: str, instruction: str) -> dict:
|
|
118
|
+
plan = create_chapter_plan(memory, instruction)
|
|
119
|
+
chapter = generate_chapter(memory, plan, instruction)
|
|
120
|
+
summary = compress_memory(chapter)
|
|
121
|
+
characters = extract_characters(chapter)
|
|
122
|
+
lore = extract_lore(chapter)
|
|
123
|
+
return {
|
|
124
|
+
"plan": plan,
|
|
125
|
+
"chapter": chapter,
|
|
126
|
+
"summary": summary,
|
|
127
|
+
"characters": characters,
|
|
128
|
+
"lore": lore,
|
|
129
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
|
|
5
|
+
from endee import Endee, Precision
|
|
6
|
+
import ollama
|
|
7
|
+
|
|
8
|
+
STORY_INDEX = "story_memory"
|
|
9
|
+
CHARACTER_INDEX = "character_memory"
|
|
10
|
+
LORE_INDEX = "lore_memory"
|
|
11
|
+
SUMMARY_INDEX = "summary_memory"
|
|
12
|
+
|
|
13
|
+
EMBED_MODEL = "nomic-embed-text"
|
|
14
|
+
|
|
15
|
+
EMBED_GPU_OPTIONS = {
|
|
16
|
+
"num_gpu": -1,
|
|
17
|
+
"main_gpu": 0,
|
|
18
|
+
"num_batch": 512,
|
|
19
|
+
"f16_kv": True,
|
|
20
|
+
"use_mmap": True,
|
|
21
|
+
"num_thread": 0,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@lru_cache(maxsize=None)
|
|
26
|
+
def _client() -> Endee:
|
|
27
|
+
return Endee()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _ensure_index(name: str):
|
|
31
|
+
client = _client()
|
|
32
|
+
try:
|
|
33
|
+
return client.get_index(name)
|
|
34
|
+
except Exception:
|
|
35
|
+
client.create_index(
|
|
36
|
+
name=name,
|
|
37
|
+
dimension=768,
|
|
38
|
+
space_type="cosine",
|
|
39
|
+
precision=Precision.INT8,
|
|
40
|
+
)
|
|
41
|
+
return client.get_index(name)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@lru_cache(maxsize=None)
|
|
45
|
+
def _story_index():
|
|
46
|
+
return _ensure_index(STORY_INDEX)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@lru_cache(maxsize=None)
|
|
50
|
+
def _character_index():
|
|
51
|
+
return _ensure_index(CHARACTER_INDEX)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@lru_cache(maxsize=None)
|
|
55
|
+
def _lore_index():
|
|
56
|
+
return _ensure_index(LORE_INDEX)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@lru_cache(maxsize=None)
|
|
60
|
+
def _summary_index():
|
|
61
|
+
return _ensure_index(SUMMARY_INDEX)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_embedding(text: str) -> list[float]:
|
|
65
|
+
response = ollama.embeddings(
|
|
66
|
+
model=EMBED_MODEL,
|
|
67
|
+
prompt=text,
|
|
68
|
+
options=EMBED_GPU_OPTIONS,
|
|
69
|
+
)
|
|
70
|
+
return response["embedding"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def chunk_text(text: str, chunk_size: int = 400) -> list[str]:
|
|
74
|
+
words = text.split()
|
|
75
|
+
return [
|
|
76
|
+
" ".join(words[i : i + chunk_size]) for i in range(0, len(words), chunk_size)
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _upsert(
|
|
81
|
+
index, novel_id: str, doc_type: str, chapter_number: int, text: str
|
|
82
|
+
) -> None:
|
|
83
|
+
vectors = []
|
|
84
|
+
for i, chunk in enumerate(chunk_text(text)):
|
|
85
|
+
vectors.append(
|
|
86
|
+
{
|
|
87
|
+
"id": f"{novel_id}_{doc_type}_{chapter_number}_{i}",
|
|
88
|
+
"vector": get_embedding(chunk),
|
|
89
|
+
"meta": {
|
|
90
|
+
"novel_id": novel_id,
|
|
91
|
+
"doc_type": doc_type,
|
|
92
|
+
"chapter": chapter_number,
|
|
93
|
+
"text": chunk,
|
|
94
|
+
},
|
|
95
|
+
"filter": {
|
|
96
|
+
"novel_id": novel_id,
|
|
97
|
+
"chapter": str(chapter_number),
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
if vectors:
|
|
102
|
+
index.upsert(vectors)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def save_story(novel_id: str, chapter_number: int, text: str) -> None:
|
|
106
|
+
_upsert(_story_index(), novel_id, "story", chapter_number, text)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def save_summary(novel_id: str, chapter_number: int, text: str) -> None:
|
|
110
|
+
_upsert(_summary_index(), novel_id, "summary", chapter_number, text)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def save_characters(novel_id: str, chapter_number: int, text: str) -> None:
|
|
114
|
+
_upsert(_character_index(), novel_id, "characters", chapter_number, text)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def save_lore(novel_id: str, chapter_number: int, text: str) -> None:
|
|
118
|
+
_upsert(_lore_index(), novel_id, "lore", chapter_number, text)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def search_index(index, novel_id: str, query: str, top_k: int = 5) -> str:
|
|
122
|
+
results = index.query(
|
|
123
|
+
vector=get_embedding(query),
|
|
124
|
+
top_k=top_k,
|
|
125
|
+
filter=[{"novel_id": {"$eq": novel_id}}],
|
|
126
|
+
)
|
|
127
|
+
memories = [
|
|
128
|
+
item["meta"]["text"] for item in results if item.get("meta", {}).get("text")
|
|
129
|
+
]
|
|
130
|
+
return "\n\n".join(memories)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def retrieve_story_memory(novel_id: str, query: str, top_k: int = 5) -> str:
|
|
134
|
+
return search_index(_story_index(), novel_id, query, top_k)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def retrieve_character_memory(novel_id: str, query: str, top_k: int = 5) -> str:
|
|
138
|
+
return search_index(_character_index(), novel_id, query, top_k)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def retrieve_lore_memory(novel_id: str, query: str, top_k: int = 5) -> str:
|
|
142
|
+
return search_index(_lore_index(), novel_id, query, top_k)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def retrieve_summary_memory(novel_id: str, query: str, top_k: int = 5) -> str:
|
|
146
|
+
return search_index(_summary_index(), novel_id, query, top_k)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def retrieve_all_memory(novel_id: str, query: str) -> str:
|
|
150
|
+
story = retrieve_story_memory(novel_id, query, top_k=8)
|
|
151
|
+
characters = retrieve_character_memory(novel_id, query, top_k=6)
|
|
152
|
+
lore = retrieve_lore_memory(novel_id, query, top_k=6)
|
|
153
|
+
summaries = retrieve_summary_memory(novel_id, query, top_k=8)
|
|
154
|
+
return f"""
|
|
155
|
+
=== STORY ===
|
|
156
|
+
{story}
|
|
157
|
+
|
|
158
|
+
=== CHARACTERS ===
|
|
159
|
+
{characters}
|
|
160
|
+
|
|
161
|
+
=== LORE ===
|
|
162
|
+
{lore}
|
|
163
|
+
|
|
164
|
+
=== SUMMARIES ===
|
|
165
|
+
{summaries}
|
|
166
|
+
"""
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
NOVELS_DIR = Path("novels")
|
|
8
|
+
NOVELS_DIR.mkdir(exist_ok=True)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NovelManager:
|
|
12
|
+
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
self.current_novel: str | None = None
|
|
15
|
+
self.metadata: dict = {}
|
|
16
|
+
|
|
17
|
+
def create_novel(self, title: str, genre: str, premise: str) -> str:
|
|
18
|
+
novel_id = str(uuid.uuid4())
|
|
19
|
+
metadata = {
|
|
20
|
+
"novel_id": novel_id,
|
|
21
|
+
"title": title,
|
|
22
|
+
"genre": genre,
|
|
23
|
+
"premise": premise,
|
|
24
|
+
"current_chapter": 1,
|
|
25
|
+
"characters": [],
|
|
26
|
+
"locations": [],
|
|
27
|
+
"factions": [],
|
|
28
|
+
"lore_topics": [],
|
|
29
|
+
"created": True,
|
|
30
|
+
}
|
|
31
|
+
self._novel_dir(novel_id).mkdir(parents=True, exist_ok=True)
|
|
32
|
+
self.save_metadata(novel_id, metadata)
|
|
33
|
+
self.current_novel = novel_id
|
|
34
|
+
self.metadata = metadata
|
|
35
|
+
return novel_id
|
|
36
|
+
|
|
37
|
+
def load_novel(self, novel_id: str) -> dict:
|
|
38
|
+
path = NOVELS_DIR / f"{novel_id}.json"
|
|
39
|
+
if not path.exists():
|
|
40
|
+
raise FileNotFoundError(f"Novel not found: {novel_id}")
|
|
41
|
+
with open(path, encoding="utf-8") as f:
|
|
42
|
+
metadata = json.load(f)
|
|
43
|
+
self.current_novel = novel_id
|
|
44
|
+
self.metadata = metadata
|
|
45
|
+
return metadata
|
|
46
|
+
|
|
47
|
+
def save_metadata(self, novel_id: str | None, metadata: dict) -> None:
|
|
48
|
+
novel_id = novel_id or self.current_novel
|
|
49
|
+
if novel_id is None:
|
|
50
|
+
raise ValueError("novel_id must be provided or current_novel must be set")
|
|
51
|
+
path = NOVELS_DIR / f"{novel_id}.json"
|
|
52
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
53
|
+
json.dump(metadata, f, indent=4, ensure_ascii=False)
|
|
54
|
+
|
|
55
|
+
def _novel_dir(self, novel_id: str | None = None) -> Path:
|
|
56
|
+
dir_id = novel_id or self.current_novel
|
|
57
|
+
if dir_id is None:
|
|
58
|
+
raise ValueError("novel_id must be provided or current_novel must be set")
|
|
59
|
+
return NOVELS_DIR / dir_id
|
|
60
|
+
|
|
61
|
+
def save_chapter_to_disk(
|
|
62
|
+
self, chapter_number: int, chapter_text: str, summary_text: str
|
|
63
|
+
) -> None:
|
|
64
|
+
d = self._novel_dir()
|
|
65
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
(d / f"chapter_{chapter_number:03d}.txt").write_text(chapter_text, "utf-8")
|
|
67
|
+
(d / f"summary_{chapter_number:03d}.txt").write_text(summary_text, "utf-8")
|
|
68
|
+
|
|
69
|
+
def read_chapter_from_disk(self, chapter_number: int) -> str | None:
|
|
70
|
+
path = self._novel_dir() / f"chapter_{chapter_number:03d}.txt"
|
|
71
|
+
return path.read_text("utf-8") if path.exists() else None
|
|
72
|
+
|
|
73
|
+
def read_summary_from_disk(self, chapter_number: int) -> str | None:
|
|
74
|
+
path = self._novel_dir() / f"summary_{chapter_number:03d}.txt"
|
|
75
|
+
return path.read_text("utf-8") if path.exists() else None
|
|
76
|
+
|
|
77
|
+
def chapter_files(self, novel_id: str | None = None) -> list[Path]:
|
|
78
|
+
d = self._novel_dir(novel_id or self.current_novel)
|
|
79
|
+
return sorted(d.glob("chapter_*.txt")) if d.exists() else []
|
|
80
|
+
|
|
81
|
+
def update_chapter(self) -> int:
|
|
82
|
+
self.metadata["current_chapter"] += 1
|
|
83
|
+
self.save_metadata(self.current_novel, self.metadata)
|
|
84
|
+
return self.metadata["current_chapter"]
|
|
85
|
+
|
|
86
|
+
def get_current_chapter(self) -> int:
|
|
87
|
+
return self.metadata.get("current_chapter", 1)
|
|
88
|
+
|
|
89
|
+
def add_character(self, name: str) -> None:
|
|
90
|
+
if name and name not in self.metadata["characters"]:
|
|
91
|
+
self.metadata["characters"].append(name)
|
|
92
|
+
self.save_metadata(self.current_novel, self.metadata)
|
|
93
|
+
|
|
94
|
+
def add_location(self, location: str) -> None:
|
|
95
|
+
if location and location not in self.metadata["locations"]:
|
|
96
|
+
self.metadata["locations"].append(location)
|
|
97
|
+
self.save_metadata(self.current_novel, self.metadata)
|
|
98
|
+
|
|
99
|
+
def add_faction(self, faction: str) -> None:
|
|
100
|
+
if faction and faction not in self.metadata["factions"]:
|
|
101
|
+
self.metadata["factions"].append(faction)
|
|
102
|
+
self.save_metadata(self.current_novel, self.metadata)
|
|
103
|
+
|
|
104
|
+
def add_lore_topic(self, topic: str) -> None:
|
|
105
|
+
if topic and topic not in self.metadata["lore_topics"]:
|
|
106
|
+
self.metadata["lore_topics"].append(topic)
|
|
107
|
+
self.save_metadata(self.current_novel, self.metadata)
|
|
108
|
+
|
|
109
|
+
def apply_lore_extraction(self, lore_text: str) -> None:
|
|
110
|
+
if not lore_text:
|
|
111
|
+
return
|
|
112
|
+
section = None
|
|
113
|
+
location_headers = {"locations", "location"}
|
|
114
|
+
faction_headers = {
|
|
115
|
+
"factions",
|
|
116
|
+
"faction",
|
|
117
|
+
"political systems",
|
|
118
|
+
"political system",
|
|
119
|
+
}
|
|
120
|
+
lore_headers = {
|
|
121
|
+
"magic systems",
|
|
122
|
+
"magic system",
|
|
123
|
+
"technology",
|
|
124
|
+
"history",
|
|
125
|
+
"world rules",
|
|
126
|
+
"lore",
|
|
127
|
+
"other",
|
|
128
|
+
}
|
|
129
|
+
for line in lore_text.splitlines():
|
|
130
|
+
stripped = line.strip()
|
|
131
|
+
if not stripped:
|
|
132
|
+
continue
|
|
133
|
+
if stripped.startswith("#"):
|
|
134
|
+
header_text = stripped.lstrip("# ").rstrip(":").lower()
|
|
135
|
+
if header_text in location_headers:
|
|
136
|
+
section = "location"
|
|
137
|
+
elif header_text in faction_headers:
|
|
138
|
+
section = "faction"
|
|
139
|
+
elif header_text in lore_headers:
|
|
140
|
+
section = "lore"
|
|
141
|
+
else:
|
|
142
|
+
section = None
|
|
143
|
+
continue
|
|
144
|
+
if stripped.startswith(("-", "*")):
|
|
145
|
+
item = stripped.lstrip("-* ").split(":")[0].strip()
|
|
146
|
+
if not item:
|
|
147
|
+
continue
|
|
148
|
+
if section == "location":
|
|
149
|
+
self.add_location(item)
|
|
150
|
+
elif section == "faction":
|
|
151
|
+
self.add_faction(item)
|
|
152
|
+
elif section == "lore":
|
|
153
|
+
self.add_lore_topic(item)
|
|
154
|
+
|
|
155
|
+
def list_novels(self) -> list[dict]:
|
|
156
|
+
out = []
|
|
157
|
+
for f in NOVELS_DIR.glob("*.json"):
|
|
158
|
+
try:
|
|
159
|
+
data = json.loads(f.read_text("utf-8"))
|
|
160
|
+
out.append(
|
|
161
|
+
{
|
|
162
|
+
"novel_id": data.get("novel_id"),
|
|
163
|
+
"title": data.get("title"),
|
|
164
|
+
"genre": data.get("genre"),
|
|
165
|
+
"chapter": data.get("current_chapter", 1),
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
def delete_novel(self, novel_id: str) -> None:
|
|
173
|
+
path = NOVELS_DIR / f"{novel_id}.json"
|
|
174
|
+
if path.exists():
|
|
175
|
+
path.unlink()
|
|
176
|
+
|
|
177
|
+
def get_metadata(self) -> dict:
|
|
178
|
+
return self.metadata
|
|
179
|
+
|
|
180
|
+
def get_novel_id(self) -> str | None:
|
|
181
|
+
return self.current_novel
|
|
182
|
+
|
|
183
|
+
def get_story_bible(self) -> str:
|
|
184
|
+
m = self.metadata
|
|
185
|
+
return (
|
|
186
|
+
f"Title:\n{m.get('title', '')}\n\n"
|
|
187
|
+
f"Genre:\n{m.get('genre', '')}\n\n"
|
|
188
|
+
f"Premise:\n{m.get('premise', '')}\n\n"
|
|
189
|
+
f"Characters:\n{', '.join(m.get('characters', []))}\n\n"
|
|
190
|
+
f"Locations:\n{', '.join(m.get('locations', []))}\n\n"
|
|
191
|
+
f"Factions:\n{', '.join(m.get('factions', []))}\n\n"
|
|
192
|
+
f"Lore:\n{', '.join(m.get('lore_topics', []))}\n\n"
|
|
193
|
+
f"Current Chapter:\n{m.get('current_chapter', 1)}"
|
|
194
|
+
)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
NOVEL_SYSTEM_PROMPT = """
|
|
2
|
+
You are an award-winning novelist.
|
|
3
|
+
|
|
4
|
+
Maintain perfect continuity across:
|
|
5
|
+
- Characters
|
|
6
|
+
- Locations
|
|
7
|
+
- Timeline
|
|
8
|
+
- Relationships
|
|
9
|
+
- Lore
|
|
10
|
+
- World rules
|
|
11
|
+
|
|
12
|
+
Rules:
|
|
13
|
+
1. Never contradict established canon.
|
|
14
|
+
2. Use Story Memory as truth.
|
|
15
|
+
3. Show, don't tell.
|
|
16
|
+
4. Use vivid sensory descriptions.
|
|
17
|
+
5. Write natural dialogue.
|
|
18
|
+
6. Advance plot and character development.
|
|
19
|
+
7. Avoid repetition.
|
|
20
|
+
8. End scenes with momentum.
|
|
21
|
+
|
|
22
|
+
Write like a bestselling novel.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
CREATE_NOVEL_PROMPT = """
|
|
26
|
+
Create Chapter 1 of a new novel.
|
|
27
|
+
|
|
28
|
+
Novel Concept:
|
|
29
|
+
{idea}
|
|
30
|
+
|
|
31
|
+
Requirements:
|
|
32
|
+
- Introduce protagonist naturally
|
|
33
|
+
- Establish setting
|
|
34
|
+
- Introduce central conflict
|
|
35
|
+
- Create emotional investment
|
|
36
|
+
- End with a hook
|
|
37
|
+
|
|
38
|
+
Write a complete chapter.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
CHAPTER_PLAN_PROMPT = """
|
|
42
|
+
Story Memory:
|
|
43
|
+
|
|
44
|
+
{memory}
|
|
45
|
+
|
|
46
|
+
Instruction:
|
|
47
|
+
|
|
48
|
+
{instruction}
|
|
49
|
+
|
|
50
|
+
Create a chapter plan.
|
|
51
|
+
|
|
52
|
+
Return:
|
|
53
|
+
|
|
54
|
+
1. Chapter Goal
|
|
55
|
+
2. Main Events
|
|
56
|
+
3. Character Development
|
|
57
|
+
4. Conflict
|
|
58
|
+
5. Ending Hook
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
CONTINUE_NOVEL_PROMPT = """
|
|
62
|
+
Story Memory:
|
|
63
|
+
|
|
64
|
+
{memory}
|
|
65
|
+
|
|
66
|
+
Chapter Plan:
|
|
67
|
+
|
|
68
|
+
{plan}
|
|
69
|
+
|
|
70
|
+
User Instruction:
|
|
71
|
+
|
|
72
|
+
{instruction}
|
|
73
|
+
|
|
74
|
+
Write the next chapter.
|
|
75
|
+
|
|
76
|
+
Maintain:
|
|
77
|
+
- Character consistency
|
|
78
|
+
- Timeline consistency
|
|
79
|
+
- Relationship consistency
|
|
80
|
+
- World consistency
|
|
81
|
+
|
|
82
|
+
Write a complete chapter.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
MEMORY_COMPRESSION_PROMPT = """
|
|
86
|
+
Chapter:
|
|
87
|
+
|
|
88
|
+
{chapter}
|
|
89
|
+
|
|
90
|
+
Extract continuity memory.
|
|
91
|
+
|
|
92
|
+
Return:
|
|
93
|
+
|
|
94
|
+
CHARACTERS:
|
|
95
|
+
LOCATIONS:
|
|
96
|
+
LORE:
|
|
97
|
+
EVENTS:
|
|
98
|
+
RELATIONSHIPS:
|
|
99
|
+
OPEN_PLOTS:
|
|
100
|
+
|
|
101
|
+
Maximum 500 words.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
CHARACTER_EXTRACTION_PROMPT = """
|
|
105
|
+
Analyze chapter:
|
|
106
|
+
|
|
107
|
+
{chapter}
|
|
108
|
+
|
|
109
|
+
Return ONLY JSON.
|
|
110
|
+
|
|
111
|
+
[
|
|
112
|
+
{{
|
|
113
|
+
"name":"",
|
|
114
|
+
"aliases":[],
|
|
115
|
+
"appearance":"",
|
|
116
|
+
"personality":"",
|
|
117
|
+
"goals":"",
|
|
118
|
+
"relationships":[],
|
|
119
|
+
"important_facts":[]
|
|
120
|
+
}}
|
|
121
|
+
]
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
LORE_EXTRACTION_PROMPT = """
|
|
125
|
+
Analyze chapter:
|
|
126
|
+
|
|
127
|
+
{chapter}
|
|
128
|
+
|
|
129
|
+
Extract:
|
|
130
|
+
|
|
131
|
+
- Locations
|
|
132
|
+
- Factions
|
|
133
|
+
- Magic systems
|
|
134
|
+
- Technology
|
|
135
|
+
- History
|
|
136
|
+
- Political systems
|
|
137
|
+
- World rules
|
|
138
|
+
|
|
139
|
+
Return structured markdown.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
QA_PROMPT = """
|
|
143
|
+
Context:
|
|
144
|
+
|
|
145
|
+
{context}
|
|
146
|
+
|
|
147
|
+
Question:
|
|
148
|
+
|
|
149
|
+
{question}
|
|
150
|
+
|
|
151
|
+
Answer only using context.
|
|
152
|
+
|
|
153
|
+
If answer is unavailable say:
|
|
154
|
+
|
|
155
|
+
Not mentioned in story.
|
|
156
|
+
"""
|