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 ADDED
@@ -0,0 +1,10 @@
1
+ """
2
+ storyforge
3
+ ~~~~~~~~~~
4
+ AI-powered novel writer — public API.
5
+ """
6
+
7
+ from novelforge.ui.app import run as launch
8
+
9
+ __all__ = ["launch"]
10
+ __version__ = "0.2.0"
novelforge/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow `python -m storyforge` and the `storyforge` console script."""
2
+
3
+ from novelforge.ui.app import run as main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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
+ """