v2.0.0: Modularize project into single-responsibility packages

Replaced monolithic modules/ package with a clean architecture:

- core/       config.py, utils.py
- ai/         models.py (ResilientModel), setup.py (init_models)
- story/      planner.py, writer.py, editor.py, style_persona.py, bible_tracker.py
- marketing/  cover.py, blurb.py, fonts.py, assets.py
- export/     exporter.py
- web/        app.py (Flask factory), db.py, helpers.py, tasks.py, routes/{auth,project,run,persona,admin}.py
- cli/        engine.py (run_generation), wizard.py (BookWizard)

Flask routes split into 5 Blueprints; all templates updated with blueprint-
prefixed url_for() calls. Dockerfile and docker-compose updated to use
web.app entry point and new package paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 22:20:53 -05:00
parent edabc4d4fa
commit f7099cc3e4
52 changed files with 3984 additions and 3798 deletions

View File

@@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(wc:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(grep:*)",
"Bash(__NEW_LINE_cd9d4130f0b9d34d__ echo \"=== Root-level files \\(non-hidden, non-dir\\) ===\")",
"Bash(ls:*)"
]
}
}

View File

@@ -14,11 +14,11 @@ RUN apt-get update && apt-get install -y \
# Copy requirements files
COPY requirements.txt .
COPY modules/requirements_web.txt ./modules/
COPY web/requirements_web.txt ./web/
# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir -r modules/requirements_web.txt
RUN pip install --no-cache-dir -r web/requirements_web.txt
# Copy the rest of the application
COPY . .
@@ -28,4 +28,4 @@ ENV PYTHONPATH=/app
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/login')" || exit 1
CMD ["python", "-m", "modules.web_app"]
CMD ["python", "-m", "web.app"]

View File

@@ -1 +0,0 @@
# BookApp Modules

0
ai/__init__.py Normal file
View File

71
ai/models.py Normal file
View File

@@ -0,0 +1,71 @@
import os
import json
import time
import warnings
import google.generativeai as genai
from core import utils
# Suppress Vertex AI warnings
warnings.filterwarnings("ignore", category=UserWarning, module="vertexai")
try:
import vertexai
from vertexai.preview.vision_models import ImageGenerationModel as VertexImageModel
HAS_VERTEX = True
except ImportError:
HAS_VERTEX = False
try:
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
HAS_OAUTH = True
except ImportError:
HAS_OAUTH = False
model_logic = None
model_writer = None
model_artist = None
model_image = None
logic_model_name = "models/gemini-1.5-pro"
writer_model_name = "models/gemini-1.5-flash"
artist_model_name = "models/gemini-1.5-flash"
image_model_name = None
image_model_source = "None"
class ResilientModel:
def __init__(self, name, safety_settings, role):
self.name = name
self.safety_settings = safety_settings
self.role = role
self.model = genai.GenerativeModel(name, safety_settings=safety_settings)
def update(self, name):
self.name = name
self.model = genai.GenerativeModel(name, safety_settings=self.safety_settings)
def generate_content(self, *args, **kwargs):
retries = 0
max_retries = 3
base_delay = 5
while True:
try:
return self.model.generate_content(*args, **kwargs)
except Exception as e:
err_str = str(e).lower()
is_retryable = "429" in err_str or "quota" in err_str or "500" in err_str or "503" in err_str or "504" in err_str or "deadline" in err_str or "internal error" in err_str
if is_retryable and retries < max_retries:
delay = base_delay * (2 ** retries)
utils.log("SYSTEM", f"⚠️ Quota error on {self.role} ({self.name}). Retrying in {delay}s...")
time.sleep(delay)
if retries == 0:
utils.log("SYSTEM", "Attempting to re-optimize models to find alternative...")
from ai import setup as _setup
_setup.init_models(force=True)
retries += 1
continue
raise e

View File

@@ -3,94 +3,31 @@ import json
import time
import warnings
import google.generativeai as genai
import config
from . import utils
from core import config, utils
from ai import models
# Suppress Vertex AI warnings
warnings.filterwarnings("ignore", category=UserWarning, module="vertexai")
try:
import vertexai
from vertexai.preview.vision_models import ImageGenerationModel as VertexImageModel
HAS_VERTEX = True
except ImportError:
HAS_VERTEX = False
try:
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
HAS_OAUTH = True
except ImportError:
HAS_OAUTH = False
model_logic = None
model_writer = None
model_artist = None
model_image = None
logic_model_name = "models/gemini-1.5-pro"
writer_model_name = "models/gemini-1.5-flash"
artist_model_name = "models/gemini-1.5-flash"
image_model_name = None
image_model_source = "None"
class ResilientModel:
def __init__(self, name, safety_settings, role):
self.name = name
self.safety_settings = safety_settings
self.role = role
self.model = genai.GenerativeModel(name, safety_settings=safety_settings)
def update(self, name):
self.name = name
self.model = genai.GenerativeModel(name, safety_settings=self.safety_settings)
def generate_content(self, *args, **kwargs):
retries = 0
max_retries = 3
base_delay = 5
while True:
try:
return self.model.generate_content(*args, **kwargs)
except Exception as e:
err_str = str(e).lower()
is_retryable = "429" in err_str or "quota" in err_str or "500" in err_str or "503" in err_str or "504" in err_str or "deadline" in err_str or "internal error" in err_str
if is_retryable and retries < max_retries:
delay = base_delay * (2 ** retries)
utils.log("SYSTEM", f"⚠️ Quota error on {self.role} ({self.name}). Retrying in {delay}s...")
time.sleep(delay)
# On first retry, attempt to re-optimize/rotate models
if retries == 0:
utils.log("SYSTEM", "Attempting to re-optimize models to find alternative...")
init_models(force=True)
# Note: init_models calls .update() on this instance
retries += 1
continue
raise e
def get_optimal_model(base_type="pro"):
try:
models = [m for m in genai.list_models() if 'generateContent' in m.supported_generation_methods]
candidates = [m.name for m in models if base_type in m.name]
available = [m for m in genai.list_models() if 'generateContent' in m.supported_generation_methods]
candidates = [m.name for m in available if base_type in m.name]
if not candidates: return f"models/gemini-1.5-{base_type}"
def score(n):
# Prefer newer generations: 2.5 > 2.0 > 1.5
gen_bonus = 0
if "2.5" in n: gen_bonus = 300
elif "2.0" in n: gen_bonus = 200
elif "2." in n: gen_bonus = 150
# Within a generation, prefer stable over experimental
if "exp" in n or "beta" in n or "preview" in n: return gen_bonus + 0
if "latest" in n: return gen_bonus + 50
return gen_bonus + 100
return sorted(candidates, key=score, reverse=True)[0]
except Exception as e:
utils.log("SYSTEM", f"⚠️ Error finding optimal model: {e}")
return f"models/gemini-1.5-{base_type}"
def get_default_models():
return {
"logic": {"model": "models/gemini-2.0-pro-exp", "reason": "Fallback: Gemini 2.0 Pro for complex reasoning and JSON adherence.", "estimated_cost": "$0.00/1M (Experimental)"},
@@ -99,27 +36,21 @@ def get_default_models():
"ranking": []
}
def select_best_models(force_refresh=False):
"""
Uses a safe bootstrapper model to analyze available models and pick the best ones.
Caches the result for 24 hours.
"""
cache_path = os.path.join(config.DATA_DIR, "model_cache.json")
cached_models = None
# 1. Check Cache
if os.path.exists(cache_path):
try:
with open(cache_path, 'r') as f:
cached = json.load(f)
cached_models = cached.get('models', {})
# Check if within 24 hours (86400 seconds)
if not force_refresh and time.time() - cached.get('timestamp', 0) < 86400:
models = cached_models
# Validate format (must be dicts with reasons, not just strings)
if isinstance(models.get('logic'), dict) and 'reason' in models['logic']:
m = cached_models
if isinstance(m.get('logic'), dict) and 'reason' in m['logic']:
utils.log("SYSTEM", "Using cached AI model selection (valid for 24h).")
return models
return m
except Exception as e:
utils.log("SYSTEM", f"Cache read failed: {e}. Refreshing models.")
@@ -129,8 +60,8 @@ def select_best_models(force_refresh=False):
raw_model_names = [m.name for m in all_models]
utils.log("SYSTEM", f"Found {len(all_models)} raw models from Google API.")
models = [m.name for m in all_models if 'generateContent' in m.supported_generation_methods and 'gemini' in m.name.lower()]
utils.log("SYSTEM", f"Identified {len(models)} compatible Gemini models: {models}")
compatible = [m.name for m in all_models if 'generateContent' in m.supported_generation_methods and 'gemini' in m.name.lower()]
utils.log("SYSTEM", f"Identified {len(compatible)} compatible Gemini models: {compatible}")
bootstrapper = get_optimal_model("flash")
utils.log("SYSTEM", f"Bootstrapping model selection with: {bootstrapper}")
@@ -141,7 +72,7 @@ def select_best_models(force_refresh=False):
TASK: Select the optimal Gemini models for a book-writing application. Prefer newer Gemini 2.x models when available.
AVAILABLE_MODELS:
{json.dumps(models)}
{json.dumps(compatible)}
PRICING_CONTEXT (USD per 1M tokens, approximate):
- Gemini 2.5 Pro/Flash: Best quality/speed; check current pricing.
@@ -185,15 +116,14 @@ def select_best_models(force_refresh=False):
json.dump({
"timestamp": int(time.time()),
"models": selection,
"available_at_time": models,
"available_at_time": compatible,
"raw_models": raw_model_names
}, f, indent=2)
return selection
except Exception as e:
utils.log("SYSTEM", f"AI Model Selection failed: {e}.")
# 3. Fallback to Stale Cache if available (Better than heuristics)
# Relaxed check: If we successfully loaded ANY JSON from the cache, use it.
if cached_models:
utils.log("SYSTEM", "⚠️ Using stale cached models due to API failure.")
return cached_models
@@ -201,20 +131,19 @@ def select_best_models(force_refresh=False):
utils.log("SYSTEM", "Falling back to heuristics.")
fallback = get_default_models()
# Save fallback to cache if file doesn't exist OR if we couldn't load it (corrupt/None)
# This ensures we have a valid file on disk for the web UI to read.
try:
with open(cache_path, 'w') as f:
json.dump({"timestamp": int(time.time()), "models": fallback, "error": str(e)}, f, indent=2)
except: pass
return fallback
def init_models(force=False):
global model_logic, model_writer, model_artist, model_image, logic_model_name, writer_model_name, artist_model_name, image_model_name, image_model_source
if model_logic and not force: return
global_vars = models.__dict__
if global_vars.get('model_logic') and not force: return
genai.configure(api_key=config.API_KEY)
# Check cache to skip frequent validation
cache_path = os.path.join(config.DATA_DIR, "model_cache.json")
skip_validation = False
if not force and os.path.exists(cache_path):
@@ -224,22 +153,19 @@ def init_models(force=False):
except: pass
if not skip_validation:
# Validate Gemini API Key
utils.log("SYSTEM", "Validating credentials...")
try:
list(genai.list_models(page_size=1))
utils.log("SYSTEM", "✅ Gemini API Key is valid.")
except Exception as e:
# Check if we have a cache file we can rely on before exiting
if os.path.exists(cache_path):
utils.log("SYSTEM", f"⚠️ API check failed ({e}), but cache exists. Attempting to use cached models.")
utils.log("SYSTEM", f"⚠️ API check failed ({e}), but cache exists. Attempting to use cached models.")
else:
utils.log("SYSTEM", f"⚠️ API check failed ({e}). No cache found. Attempting to initialize with defaults.")
utils.log("SYSTEM", "Selecting optimal models via AI...")
selected_models = select_best_models(force_refresh=force)
# Check for missing costs and force refresh if needed
if not force:
missing_costs = False
for role in ['logic', 'writer', 'artist']:
@@ -257,57 +183,52 @@ def init_models(force=False):
writer_name, writer_cost = get_model_details(selected_models['writer'])
artist_name, artist_cost = get_model_details(selected_models['artist'])
logic_name = logic_model_name = logic_name if config.MODEL_LOGIC_HINT == "AUTO" else config.MODEL_LOGIC_HINT
writer_name = writer_model_name = writer_name if config.MODEL_WRITER_HINT == "AUTO" else config.MODEL_WRITER_HINT
artist_name = artist_model_name = artist_name if config.MODEL_ARTIST_HINT == "AUTO" else config.MODEL_ARTIST_HINT
logic_name = logic_name if config.MODEL_LOGIC_HINT == "AUTO" else config.MODEL_LOGIC_HINT
writer_name = writer_name if config.MODEL_WRITER_HINT == "AUTO" else config.MODEL_WRITER_HINT
artist_name = artist_name if config.MODEL_ARTIST_HINT == "AUTO" else config.MODEL_ARTIST_HINT
models.logic_model_name = logic_name
models.writer_model_name = writer_name
models.artist_model_name = artist_name
utils.log("SYSTEM", f"Models: Logic={logic_name} ({logic_cost}) | Writer={writer_name} ({writer_cost}) | Artist={artist_name}")
# Update pricing in utils
utils.update_pricing(logic_name, logic_cost)
utils.update_pricing(writer_name, writer_cost)
utils.update_pricing(artist_name, artist_cost)
# Initialize or Update Resilient Models
if model_logic is None:
model_logic = ResilientModel(logic_name, utils.SAFETY_SETTINGS, "Logic")
model_writer = ResilientModel(writer_name, utils.SAFETY_SETTINGS, "Writer")
model_artist = ResilientModel(artist_name, utils.SAFETY_SETTINGS, "Artist")
if models.model_logic is None:
models.model_logic = models.ResilientModel(logic_name, utils.SAFETY_SETTINGS, "Logic")
models.model_writer = models.ResilientModel(writer_name, utils.SAFETY_SETTINGS, "Writer")
models.model_artist = models.ResilientModel(artist_name, utils.SAFETY_SETTINGS, "Artist")
else:
# If models already exist (re-init), update them in place
model_logic.update(logic_name)
model_writer.update(writer_name)
model_artist.update(artist_name)
models.model_logic.update(logic_name)
models.model_writer.update(writer_name)
models.model_artist.update(artist_name)
# Initialize Image Model
model_image = None
image_model_name = None
image_model_source = "None"
models.model_image = None
models.image_model_name = None
models.image_model_source = "None"
hint = config.MODEL_IMAGE_HINT if hasattr(config, 'MODEL_IMAGE_HINT') else "AUTO"
if hasattr(genai, 'ImageGenerationModel'):
# Candidate image models in preference order
if hint and hint != "AUTO":
candidates = [hint]
else:
candidates = ["imagen-3.0-generate-001", "imagen-3.0-fast-generate-001"]
candidates = [hint] if hint and hint != "AUTO" else ["imagen-3.0-generate-001", "imagen-3.0-fast-generate-001"]
for candidate in candidates:
try:
model_image = genai.ImageGenerationModel(candidate)
image_model_name = candidate
image_model_source = "Gemini API"
models.model_image = genai.ImageGenerationModel(candidate)
models.image_model_name = candidate
models.image_model_source = "Gemini API"
utils.log("SYSTEM", f"✅ Image model: {candidate} (Gemini API)")
break
except Exception:
continue
# Auto-detect GCP Project from credentials if not set (Fix for Image Model)
if HAS_VERTEX and not config.GCP_PROJECT and config.GOOGLE_CREDS and os.path.exists(config.GOOGLE_CREDS):
# Auto-detect GCP Project
if models.HAS_VERTEX and not config.GCP_PROJECT and config.GOOGLE_CREDS and os.path.exists(config.GOOGLE_CREDS):
try:
with open(config.GOOGLE_CREDS, 'r') as f:
cdata = json.load(f)
# Check common OAuth structures
for k in ['installed', 'web']:
if k in cdata and 'project_id' in cdata[k]:
config.GCP_PROJECT = cdata[k]['project_id']
@@ -315,16 +236,14 @@ def init_models(force=False):
break
except: pass
if HAS_VERTEX and config.GCP_PROJECT:
if models.HAS_VERTEX and config.GCP_PROJECT:
creds = None
# Handle OAuth Client ID (credentials.json) if provided instead of Service Account
if HAS_OAUTH:
gac = config.GOOGLE_CREDS # Use persistent config, not volatile env var
if models.HAS_OAUTH:
gac = config.GOOGLE_CREDS
if gac and os.path.exists(gac):
try:
with open(gac, 'r') as f: data = json.load(f)
if 'installed' in data or 'web' in data:
# It's an OAuth Client ID. Unset env var to avoid library crash.
if "GOOGLE_APPLICATION_CREDENTIALS" in os.environ:
del os.environ["GOOGLE_APPLICATION_CREDENTIALS"]
@@ -332,19 +251,19 @@ def init_models(force=False):
SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
if os.path.exists(token_path):
creds = Credentials.from_authorized_user_file(token_path, SCOPES)
creds = models.Credentials.from_authorized_user_file(token_path, SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
try:
creds.refresh(Request())
creds.refresh(models.Request())
except Exception:
utils.log("SYSTEM", "Token refresh failed. Re-authenticating...")
flow = InstalledAppFlow.from_client_secrets_file(gac, SCOPES)
flow = models.InstalledAppFlow.from_client_secrets_file(gac, SCOPES)
creds = flow.run_local_server(port=0)
else:
utils.log("SYSTEM", "OAuth Client ID detected. Launching browser to authenticate...")
flow = InstalledAppFlow.from_client_secrets_file(gac, SCOPES)
flow = models.InstalledAppFlow.from_client_secrets_file(gac, SCOPES)
creds = flow.run_local_server(port=0)
with open(token_path, 'w') as token: token.write(creds.to_json())
@@ -352,21 +271,19 @@ def init_models(force=False):
except Exception as e:
utils.log("SYSTEM", f"⚠️ OAuth check failed: {e}")
vertexai.init(project=config.GCP_PROJECT, location=config.GCP_LOCATION, credentials=creds)
import vertexai as _vertexai
_vertexai.init(project=config.GCP_PROJECT, location=config.GCP_LOCATION, credentials=creds)
utils.log("SYSTEM", f"✅ Vertex AI initialized (Project: {config.GCP_PROJECT})")
# Override with Vertex Image Model if available
vertex_candidates = ["imagen-3.0-generate-001", "imagen-3.0-fast-generate-001"]
if hint and hint != "AUTO":
vertex_candidates = [hint]
vertex_candidates = [hint] if hint and hint != "AUTO" else ["imagen-3.0-generate-001", "imagen-3.0-fast-generate-001"]
for candidate in vertex_candidates:
try:
model_image = VertexImageModel.from_pretrained(candidate)
image_model_name = candidate
image_model_source = "Vertex AI"
models.model_image = models.VertexImageModel.from_pretrained(candidate)
models.image_model_name = candidate
models.image_model_source = "Vertex AI"
utils.log("SYSTEM", f"✅ Image model: {candidate} (Vertex AI)")
break
except Exception:
continue
utils.log("SYSTEM", f"Image Generation Provider: {image_model_source} ({image_model_name or 'unavailable'})")
utils.log("SYSTEM", f"Image Generation Provider: {models.image_model_source} ({models.image_model_name or 'unavailable'})")

312
ai_blueprint.md Normal file
View File

@@ -0,0 +1,312 @@
# AI Blueprint: Modularization Plan
This blueprint details the strategy to break down the monolithic files (`main.py`, `wizard.py`, `modules/story.py`, `modules/web_app.py`, `modules/marketing.py`, `modules/web_tasks.py`, `modules/ai.py`) into a small-file, Single Responsibility architecture.
## Proposed Folder Structure
```
c:/Users/thethreemagi/OneDrive/Gemini/BookApp/
├── core/
│ ├── config.py
│ └── utils.py
├── ai/
│ ├── setup.py
│ └── models.py
├── story/
│ ├── planner.py
│ ├── writer.py
│ ├── editor.py
│ ├── style_persona.py
│ └── bible_tracker.py
├── marketing/
│ ├── blurb.py
│ ├── cover.py
│ ├── fonts.py
│ └── assets.py
├── export/
│ └── exporter.py
├── web/
│ ├── app.py
│ ├── db.py
│ ├── tasks.py
│ └── routes/
│ ├── auth.py
│ ├── project.py
│ ├── run.py
│ ├── admin.py
│ └── persona.py
├── cli/
│ ├── engine.py
│ └── wizard.py
```
## Step-by-Step Migration Details
### 1. `core/` Module
**New File: `core/config.py`**
- Moves `config.py` unchanged.
**New File: `core/utils.py`**
- Moves all functions from `modules/utils.py`.
- **Exact Imports:**
```python
import os, json, datetime, time, threading, re
from core import config
```
### 2. `ai/` Module (Extracting from `modules/ai.py`)
**New File: `ai/models.py`**
- Extract `ResilientModel` class, and global model variables (`model_logic`, `model_writer`, etc.).
- **Exact Imports:**
```python
import google.generativeai as genai
from core import utils
```
**New File: `ai/setup.py`**
- Extract `init_models`, `select_best_models`, `get_optimal_model`, `get_default_models`.
- **Exact Imports:**
```python
import os, json, time, warnings
import google.generativeai as genai
from core import config, utils
from ai import models
```
### 3. `story/` Module (Extracting from `modules/story.py`)
**New File: `story/planner.py`**
- Extract `enrich`, `plan_structure`, `expand`, `create_chapter_plan`.
- **Exact Imports:**
```python
import json, random
from core import utils
from ai import models as ai_models
from story.bible_tracker import filter_characters
```
**New File: `story/writer.py`**
- Extract `write_chapter`.
- **Exact Imports:**
```python
import json, os
from core import config, utils
from ai import models as ai_models
from story.style_persona import get_style_guidelines
from story.editor import evaluate_chapter_quality
```
**New File: `story/editor.py`**
- Extract `evaluate_chapter_quality`, `check_pacing`, `analyze_consistency`, `rewrite_chapter_content`, `check_and_propagate`.
- **Exact Imports:**
```python
import json, os
from core import config, utils
from ai import models as ai_models
from story.style_persona import get_style_guidelines
```
**New File: `story/style_persona.py`**
- Extract `get_style_guidelines`, `refresh_style_guidelines`, `create_initial_persona`, `refine_persona`, `update_persona_sample`.
- **Exact Imports:**
```python
import json, os, time
from core import config, utils
from ai import models as ai_models
```
**New File: `story/bible_tracker.py`**
- Extract `merge_selected_changes`, `filter_characters`, `update_tracking`, `harvest_metadata`, `refine_bible`.
- **Exact Imports:**
```python
import json
from core import utils
from ai import models as ai_models
```
### 4. `marketing/` Module (Extracting from `modules/marketing.py`)
**New File: `marketing/fonts.py`**
- Extract `download_font`.
- **Exact Imports:**
```python
import os, requests
from core import config, utils
```
**New File: `marketing/cover.py`**
- Extract `generate_cover`, `evaluate_image_quality`.
- **Exact Imports:**
```python
import os, json, shutil, textwrap, subprocess, sys
from core import config, utils
from ai import models as ai_models
from marketing.fonts import download_font
from rich.prompt import Confirm
try:
from PIL import Image, ImageDraw, ImageFont
HAS_PIL = True
except ImportError:
HAS_PIL = False
```
**New File: `marketing/blurb.py`**
- Extract `generate_blurb`.
- **Exact Imports:**
```python
import os
from core import utils
from ai import models as ai_models
```
**New File: `marketing/assets.py`**
- Extract `create_marketing_assets`.
- **Exact Imports:**
```python
from marketing.blurb import generate_blurb
from marketing.cover import generate_cover
```
### 5. `export/` Module (Extracting from `modules/export.py`)
**New File: `export/exporter.py`**
- Extract `create_readme`, `compile_files`.
- **Exact Imports:**
```python
import os, markdown
from docx import Document
from ebooklib import epub
from core import utils
```
### 6. `web/` Module (Extracting from `modules/web_app.py` and `modules/web_tasks.py`)
**New File: `web/db.py`**
- Extract models `User`, `Project`, `Run`, `LogEntry` from `modules/web_db.py`.
- **Exact Imports:**
```python
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from datetime import datetime
db = SQLAlchemy()
```
**New File: `web/tasks.py`**
- Extract Huey configuration and tasks `generate_book_task`, `regenerate_artifacts_task`, `rewrite_chapter_task`, `refine_bible_task`.
- **Exact Imports:**
```python
import os, json, time, sqlite3, shutil
from datetime import datetime
from huey import SqliteHuey
from core import config, utils
from ai import setup as ai_setup
from story import planner, writer, editor, style_persona, bible_tracker
from marketing import cover, assets
from export import exporter
from web.db import db, Run, User, Project
```
**New File: `web/routes/auth.py`**
- Extract `/login`, `/register`, `/logout`.
- **Exact Imports:**
```python
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_user, login_required, logout_user, current_user
from werkzeug.security import generate_password_hash, check_password_hash
from web.db import db, User
```
**New File: `web/routes/project.py`**
- Extract `/`, `/project/setup`, `/project/setup/refine`, `/project/create`, `/project/import`, `/project/<id>`, `/project/<id>/update`, `/project/<id>/clone`, `/project/<id>/add_book`, `/project/<id>/book/<book_num>/update`, `/project/<id>/delete_book/<book_num>`, `/project/<id>/import_characters`, `/project/<id>/set_persona`.
- **Exact Imports:**
```python
import os, json
from datetime import datetime
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from core import config, utils
from ai import setup as ai_setup, models as ai_models
from web.db import db, Project, Run
from story import planner, bible_tracker
```
**New File: `web/routes/run.py`**
- Extract `/project/<id>/run`, `/run/<id>`, `/run/<id>/status`, `/run/<id>/stop`, `/run/<id>/restart`, `/project/<run_id>/regenerate_artifacts`, `/project/<run_id>/revise_book/<book>`, `/project/<run_id>/read/<book>`, `/project/<run_id>/save_chapter`, `/project/<run_id>/check_consistency/<book>`, `/project/<run_id>/sync_book/<book>`, `/project/<run_id>/rewrite_chapter`, `/project/<run_id>/download`, `/task_status/<task_id>`.
- **Exact Imports:**
```python
import os, json, markdown
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_from_directory, session
from flask_login import login_required, current_user
from web.db import db, Project, Run, LogEntry
from web.tasks import huey, generate_book_task, regenerate_artifacts_task, rewrite_chapter_task
from core import config, utils
from story import editor, bible_tracker, style_persona
from export import exporter
```
**New File: `web/routes/persona.py`**
- Extract `/personas`, `/persona/new`, `/persona/<name>`, `/persona/save`, `/persona/delete/<name>`, `/persona/analyze`.
- **Exact Imports:**
```python
import os, json
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_required
from core import config, utils
from ai import setup as ai_setup, models as ai_models
```
**New File: `web/routes/admin.py`**
- Extract `/admin`, `/admin/user/<id>/delete`, `/admin/project/<id>/delete`, `/admin/reset`, `/admin/spend`, `/admin/style`, `/admin/impersonate/<id>`, `/admin/stop_impersonate`, `/debug/routes`, `/system/optimize_models`, `/system/status`.
- **Exact Imports:**
```python
import os, json, shutil
from datetime import datetime, timedelta
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from flask_login import login_required, current_user, login_user
from sqlalchemy import func
from web.db import db, User, Project, Run
from core import config, utils
from story import style_persona
from ai import setup as ai_setup, models as ai_models
```
**New File: `web/app.py`**
- Flask initialization, registering blueprints, and the `__main__` entry point.
- **Exact Imports:**
```python
import os
from flask import Flask
from flask_login import LoginManager
from core import config
from web.db import db, User
```
### 7. `cli/` Module
**New File: `cli/engine.py`**
- Extract `process_book` and `run_generation` from `main.py`.
- **Exact Imports:**
```python
import json, os, time, sys, shutil
from rich.prompt import Confirm
from core import config, utils
from ai import setup as ai_setup
from story import planner, writer, editor, style_persona, bible_tracker
from marketing import cover, assets
from export import exporter
```
**New File: `cli/wizard.py`**
- Move `wizard.py` here and update imports.
- **Exact Imports:**
```python
import os, sys, json
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Prompt, IntPrompt, Confirm
from rich.table import Table
from flask import Flask
from core import config, utils
from ai import setup as ai_setup, models as ai_models
from web.db import db, User, Project
```
## HTML Template Changes
The `templates/` folder contains HTML files. Ensure any `url_for()` calls reference the correct Blueprint names (e.g., `url_for('auth.login')` instead of `url_for('login')`).
Awaiting approval to begin creating files and making structural changes.

0
cli/__init__.py Normal file
View File

View File

@@ -1,7 +1,17 @@
import json, os, time, sys, shutil
import config
import json
import os
import time
import sys
import shutil
from rich.prompt import Confirm
from modules import ai, story, marketing, export, utils
from core import config, utils
from ai import models as ai_models
from ai import setup as ai_setup
from story import planner, writer as story_writer, editor as story_editor
from story import style_persona, bible_tracker
from marketing import assets as marketing_assets
from export import exporter
def process_book(bp, folder, context="", resume=False, interactive=False):
# Create lock file to indicate active processing
@@ -14,8 +24,6 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
# 1. Check completion
if resume and os.path.exists(os.path.join(folder, "final_blueprint.json")):
utils.log("SYSTEM", f"Book in {folder} already finished. Skipping.")
# Clean up zombie lock file if it exists
if os.path.exists(lock_path): os.remove(lock_path)
return
@@ -26,7 +34,6 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
if resume and os.path.exists(bp_path):
utils.log("RESUME", "Loading existing blueprint...")
saved_bp = utils.load_json(bp_path)
# Merge latest metadata from Bible (passed in bp) into saved blueprint
if saved_bp:
if 'book_metadata' in bp and 'book_metadata' in saved_bp:
for k in ['title', 'author', 'genre', 'target_audience', 'style', 'author_bio', 'author_details']:
@@ -37,12 +44,12 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
bp = saved_bp
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
else:
bp = story.enrich(bp, folder, context)
bp = planner.enrich(bp, folder, context)
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
# Ensure Persona Exists (Auto-create if missing)
if 'author_details' not in bp['book_metadata'] or not bp['book_metadata']['author_details']:
bp['book_metadata']['author_details'] = story.create_initial_persona(bp, folder)
bp['book_metadata']['author_details'] = style_persona.create_initial_persona(bp, folder)
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
utils.log("TIMING", f"Blueprint Phase: {time.time() - t_step:.1f}s")
@@ -55,11 +62,11 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
utils.log("RESUME", "Loading existing events...")
events = utils.load_json(events_path)
else:
events = story.plan_structure(bp, folder)
events = planner.plan_structure(bp, folder)
depth = bp['length_settings']['depth']
target_chaps = bp['length_settings']['chapters']
for d in range(1, depth+1):
events = story.expand(events, d, target_chaps, bp, folder)
events = planner.expand(events, d, target_chaps, bp, folder)
time.sleep(1)
with open(events_path, "w") as f: json.dump(events, f, indent=2)
utils.log("TIMING", f"Structure & Expansion: {time.time() - t_step:.1f}s")
@@ -72,7 +79,7 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
utils.log("RESUME", "Loading existing chapter plan...")
chapters = utils.load_json(chapters_path)
else:
chapters = story.create_chapter_plan(events, bp, folder)
chapters = planner.create_chapter_plan(events, bp, folder)
with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2)
utils.log("TIMING", f"Chapter Planning: {time.time() - t_step:.1f}s")
@@ -97,12 +104,11 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
summary = "The story begins."
if ms:
# Efficient rebuild: first chapter (setup) + last 4 (recent events) avoids huge prompts
utils.log("RESUME", f"Rebuilding story context from {len(ms)} existing chapters...")
try:
selected = ms[:1] + ms[-4:] if len(ms) > 5 else ms
combined_text = "\n".join([f"Chapter {c['num']}: {c['content'][:3000]}" for c in selected])
resp_sum = ai.model_writer.generate_content(f"""
resp_sum = ai_models.model_writer.generate_content(f"""
ROLE: Series Historian
TASK: Create a cumulative 'Story So Far' summary.
INPUT_TEXT:
@@ -110,7 +116,7 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
INSTRUCTIONS: Use dense, factual bullet points. Focus on character meetings, relationships, and known information.
OUTPUT: Summary text.
""")
utils.log_usage(folder, ai.model_writer.name, resp_sum.usage_metadata)
utils.log_usage(folder, ai_models.model_writer.name, resp_sum.usage_metadata)
summary = resp_sum.text
except: summary = "The story continues."
@@ -126,28 +132,25 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
# Check for stop signal from Web UI
run_dir = os.path.dirname(folder)
if os.path.exists(os.path.join(run_dir, ".stop")):
utils.log("SYSTEM", "🛑 Stop signal detected. Aborting generation.")
utils.log("SYSTEM", "Stop signal detected. Aborting generation.")
break
# Robust Resume: Check if this specific chapter number is already in the manuscript
# (Handles cases where plan changed or ms is out of sync with index)
if any(str(c.get('num')) == str(ch['chapter_number']) for c in ms):
i += 1
continue
# Progress Banner — update bar and log chapter header before writing begins
# Progress Banner
utils.update_progress(15 + int((i / len(chapters)) * 75))
utils.log_banner("WRITER", f"Chapter {ch['chapter_number']}/{len(chapters)}: {ch['title']}")
# Pass previous chapter content for continuity if available
prev_content = ms[-1]['content'] if ms else None
while True:
try:
# Cap summary to most-recent 8000 chars; pass next chapter title as hook hint
summary_ctx = summary[-8000:] if len(summary) > 8000 else summary
next_hint = chapters[i+1]['title'] if i + 1 < len(chapters) else ""
txt = story.write_chapter(ch, bp, folder, summary_ctx, tracking, prev_content, next_chapter_hint=next_hint)
txt = story_writer.write_chapter(ch, bp, folder, summary_ctx, tracking, prev_content, next_chapter_hint=next_hint)
except Exception as e:
utils.log("SYSTEM", f"Chapter generation failed: {e}")
if interactive:
@@ -164,12 +167,12 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
else:
break
# Refine Persona to match the actual output (every 5 chapters to save API calls)
# Refine Persona to match the actual output (every 5 chapters)
if (i == 0 or i % 5 == 0) and txt:
bp['book_metadata']['author_details'] = story.refine_persona(bp, txt, folder)
bp['book_metadata']['author_details'] = style_persona.refine_persona(bp, txt, folder)
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
# Look ahead for context to ensure relevant details are captured
# Look ahead for context
next_info = ""
if i + 1 < len(chapters):
next_ch = chapters[i+1]
@@ -195,13 +198,13 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
OUTPUT: Updated summary text.
"""
resp_sum = ai.model_writer.generate_content(update_prompt)
utils.log_usage(folder, ai.model_writer.name, resp_sum.usage_metadata)
resp_sum = ai_models.model_writer.generate_content(update_prompt)
utils.log_usage(folder, ai_models.model_writer.name, resp_sum.usage_metadata)
summary = resp_sum.text
except:
try:
resp_fallback = ai.model_writer.generate_content(f"ROLE: Summarizer\nTASK: Summarize plot points.\nTEXT: {txt}\nOUTPUT: Bullet points.")
utils.log_usage(folder, ai.model_writer.name, resp_fallback.usage_metadata)
resp_fallback = ai_models.model_writer.generate_content(f"ROLE: Summarizer\nTASK: Summarize plot points.\nTEXT: {txt}\nOUTPUT: Bullet points.")
utils.log_usage(folder, ai_models.model_writer.name, resp_fallback.usage_metadata)
summary += f"\n\nChapter {ch['chapter_number']}: " + resp_fallback.text
except: summary += f"\n\nChapter {ch['chapter_number']}: [Content processed]"
@@ -210,18 +213,17 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
with open(ms_path, "w") as f: json.dump(ms, f, indent=2)
# Update Tracking
tracking = story.update_tracking(folder, ch['chapter_number'], txt, tracking)
tracking = bible_tracker.update_tracking(folder, ch['chapter_number'], txt, tracking)
with open(events_track_path, "w") as f: json.dump(tracking['events'], f, indent=2)
with open(chars_track_path, "w") as f: json.dump(tracking['characters'], f, indent=2)
with open(warn_track_path, "w") as f: json.dump(tracking.get('content_warnings', []), f, indent=2)
# --- DYNAMIC PACING CHECK (every other chapter to halve API overhead) ---
# Dynamic Pacing Check (every other chapter)
remaining = chapters[i+1:]
if remaining and len(remaining) >= 2 and i % 2 == 1:
pacing = story.check_pacing(bp, summary, txt, ch, remaining, folder)
pacing = story_editor.check_pacing(bp, summary, txt, ch, remaining, folder)
if pacing and pacing.get('status') == 'add_bridge':
new_data = pacing.get('new_chapter', {})
# Estimate bridge chapter length from current plan average (not hardcoded)
if chapters:
avg_words = int(sum(c.get('estimated_words', 1500) for c in chapters) / len(chapters))
else:
@@ -235,19 +237,15 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
"beats": new_data.get('beats', [])
}
chapters.insert(i+1, new_ch)
# Renumber subsequent chapters
for k in range(i+1, len(chapters)): chapters[k]['chapter_number'] = k + 1
with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2)
utils.log("ARCHITECT", f" -> ⚠️ Pacing Intervention: Added bridge chapter '{new_ch['title']}' to fix rushing.")
utils.log("ARCHITECT", f" -> Pacing Intervention: Added bridge chapter '{new_ch['title']}' to fix rushing.")
elif pacing and pacing.get('status') == 'cut_next':
removed = chapters.pop(i+1)
# Renumber subsequent chapters
for k in range(i+1, len(chapters)): chapters[k]['chapter_number'] = k + 1
with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2)
utils.log("ARCHITECT", f" -> ⚠️ Pacing Intervention: Removed redundant chapter '{removed['title']}'.")
utils.log("ARCHITECT", f" -> Pacing Intervention: Removed redundant chapter '{removed['title']}'.")
elif pacing:
utils.log("ARCHITECT", f" -> Pacing OK. {pacing.get('reason', '')[:100]}")
@@ -260,7 +258,6 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
avg_time = session_time / session_chapters
eta = avg_time * (len(chapters) - (i + 1))
# Calculate Progress (15% to 90%)
prog = 15 + int((i / len(chapters)) * 75)
utils.update_progress(prog)
@@ -272,28 +269,27 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
# Harvest
t_step = time.time()
utils.update_progress(92)
bp = story.harvest_metadata(bp, folder, ms)
bp = bible_tracker.harvest_metadata(bp, folder, ms)
with open(os.path.join(folder, "final_blueprint.json"), "w") as f: json.dump(bp, f, indent=2)
# Create Assets
utils.update_progress(95)
marketing.create_marketing_assets(bp, folder, tracking, interactive=interactive)
marketing_assets.create_marketing_assets(bp, folder, tracking, interactive=interactive)
# Update Persona
story.update_persona_sample(bp, folder)
style_persona.update_persona_sample(bp, folder)
utils.update_progress(98)
export.compile_files(bp, ms, folder)
exporter.compile_files(bp, ms, folder)
utils.log("TIMING", f"Post-Processing: {time.time() - t_step:.1f}s")
utils.log("SYSTEM", f"Book Finished. Total Time: {time.time() - total_start:.1f}s")
finally:
# Remove lock file on success or failure
if os.path.exists(lock_path): os.remove(lock_path)
# --- 6. ENTRY POINT ---
def run_generation(target=None, specific_run_id=None, interactive=False):
ai.init_models()
ai_setup.init_models()
if not target: target = config.DEFAULT_BLUEPRINT
data = utils.load_json(target)
@@ -302,10 +298,8 @@ def run_generation(target=None, specific_run_id=None, interactive=False):
utils.log("SYSTEM", f"Could not load {target}")
return
# --- BIBLE FORMAT ---
utils.log("SYSTEM", "Starting Series Generation...")
# Determine Run Directory: projects/{Project}/runs/run_X
project_dir = os.path.dirname(os.path.abspath(target))
runs_base = os.path.join(project_dir, "runs")
@@ -313,12 +307,10 @@ def run_generation(target=None, specific_run_id=None, interactive=False):
resume_mode = False
if specific_run_id:
# WEB/WORKER MODE: Non-interactive, specific ID
run_dir = os.path.join(runs_base, f"run_{specific_run_id}")
if not os.path.exists(run_dir): os.makedirs(run_dir)
resume_mode = True # Always try to resume if files exist in this specific run
resume_mode = True
else:
# CLI MODE: Interactive checks
latest_run = utils.get_latest_run_folder(runs_base)
if latest_run:
has_lock = False
@@ -344,12 +336,10 @@ def run_generation(target=None, specific_run_id=None, interactive=False):
for i, book in enumerate(data['books']):
utils.log("SERIES", f"Processing Book {book.get('book_number')}: {book.get('title')}")
# Check for stop signal at book level
if os.path.exists(os.path.join(run_dir, ".stop")):
utils.log("SYSTEM", "🛑 Stop signal detected. Aborting series generation.")
utils.log("SYSTEM", "Stop signal detected. Aborting series generation.")
break
# Adapter: Bible -> Blueprint
meta = data['project_metadata']
bp = {
"book_metadata": {
@@ -374,34 +364,26 @@ def run_generation(target=None, specific_run_id=None, interactive=False):
}
}
# Create Book Subfolder
safe_title = utils.sanitize_filename(book.get('title', f"Book_{i+1}"))
book_folder = os.path.join(run_dir, f"Book_{book.get('book_number', i+1)}_{safe_title}")
os.makedirs(book_folder, exist_ok=True)
# Process
process_book(bp, book_folder, context=previous_context, resume=resume_mode, interactive=interactive)
# Update Context for next book
final_bp_path = os.path.join(book_folder, "final_blueprint.json")
if os.path.exists(final_bp_path):
final_bp = utils.load_json(final_bp_path)
# --- Update World Bible with new characters ---
# This ensures future books know about characters invented in this book
new_chars = final_bp.get('characters', [])
# RELOAD BIBLE to avoid race conditions (User might have edited it in UI)
if os.path.exists(target):
current_bible = utils.load_json(target)
# 1. Merge New Characters
existing_names = {c['name'].lower() for c in current_bible.get('characters', [])}
for char in new_chars:
if char['name'].lower() not in existing_names:
current_bible['characters'].append(char)
# 2. Sync Generated Book Metadata (Title, Beats) back to Bible
for b in current_bible.get('books', []):
if b.get('book_number') == book.get('book_number'):
b['title'] = final_bp['book_metadata'].get('title', b.get('title'))
@@ -417,6 +399,7 @@ def run_generation(target=None, specific_run_id=None, interactive=False):
return
if __name__ == "__main__":
target_arg = sys.argv[1] if len(sys.argv) > 1 else None
run_generation(target_arg, interactive=True)

View File

@@ -1,21 +1,24 @@
import os
import sys
import json
import config
import google.generativeai as genai
from flask import Flask
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Prompt, IntPrompt, Confirm
from rich.table import Table
from modules import ai, utils
from modules.web_db import db, User, Project
from core import config, utils
from ai import models as ai_models
from ai import setup as ai_setup
from web.db import db, User, Project
from marketing import cover as marketing_cover
from export import exporter
from cli.engine import run_generation
console = Console()
try:
ai.init_models()
ai_setup.init_models()
except Exception as e:
console.print(f"[bold red]CRITICAL: AI Model Initialization failed.[/bold red]")
console.print(f"[bold red]CRITICAL: AI Model Initialization failed.[/bold red]")
console.print(f"[red]Error: {e}[/red]")
Prompt.ask("Press Enter to exit...")
sys.exit(1)
@@ -30,6 +33,7 @@ db.init_app(app)
if not os.path.exists(config.PROJECTS_DIR): os.makedirs(config.PROJECTS_DIR)
if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR)
class BookWizard:
def __init__(self):
self.project_name = "New_Project"
@@ -43,11 +47,10 @@ class BookWizard:
utils.create_default_personas()
def _get_or_create_wizard_user(self):
# Find or create a default user for CLI operations
wizard_user = User.query.filter_by(username="wizard").first()
if not wizard_user:
console.print("[yellow]Creating default 'wizard' user for CLI operations...[/yellow]")
wizard_user = User(username="wizard", password="!", is_admin=True) # Password not used for CLI
wizard_user = User(username="wizard", password="!", is_admin=True)
db.session.add(wizard_user)
db.session.commit()
return wizard_user
@@ -57,7 +60,7 @@ class BookWizard:
def ask_gemini_json(self, prompt):
text = None
try:
response = ai.model_logic.generate_content(prompt + "\nReturn ONLY valid JSON.")
response = ai_models.model_logic.generate_content(prompt + "\nReturn ONLY valid JSON.")
text = utils.clean_json(response.text)
return json.loads(text)
except Exception as e:
@@ -67,7 +70,7 @@ class BookWizard:
def ask_gemini_text(self, prompt):
try:
response = ai.model_logic.generate_content(prompt)
response = ai_models.model_logic.generate_content(prompt)
return response.text.strip()
except Exception as e:
console.print(f"[red]AI Error: {e}[/red]")
@@ -82,7 +85,7 @@ class BookWizard:
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
except: pass
console.print(Panel("[bold cyan]🎭 Manage Author Personas[/bold cyan]"))
console.print(Panel("[bold cyan]Manage Author Personas[/bold cyan]"))
options = list(personas.keys())
for i, name in enumerate(options):
@@ -101,11 +104,9 @@ class BookWizard:
details = {}
if choice == len(options) + 1:
# Create
console.print("[yellow]Define New Persona[/yellow]")
selected_key = Prompt.ask("Persona Label (e.g. 'Gritty Detective')", default="New Persona")
else:
# Edit/Delete Menu for specific persona
selected_key = options[choice-1]
details = personas[selected_key]
if isinstance(details, str): details = {"bio": details}
@@ -124,7 +125,6 @@ class BookWizard:
elif sub == 3:
continue
# Edit Fields
details['name'] = Prompt.ask("Author Name/Pseudonym", default=details.get('name', "AI Author"))
details['age'] = Prompt.ask("Age", default=details.get('age', "Unknown"))
details['gender'] = Prompt.ask("Gender", default=details.get('gender', "Unknown"))
@@ -133,7 +133,6 @@ class BookWizard:
details['language'] = Prompt.ask("Primary Language/Dialect", default=details.get('language', "Standard English"))
details['bio'] = Prompt.ask("Writing Style/Bio", default=details.get('bio', ""))
# Samples
console.print("\n[bold]Style Samples[/bold]")
console.print(f"Place text files in the '{config.PERSONAS_DIR}' folder to reference them.")
@@ -151,7 +150,7 @@ class BookWizard:
def select_mode(self):
while True:
self.clear()
console.print(Panel("[bold blue]🧙‍♂️ BookApp Setup Wizard[/bold blue]"))
console.print(Panel("[bold blue]BookApp Setup Wizard[/bold blue]"))
console.print("1. Create New Project")
console.print("2. Open Existing Project")
console.print("3. Manage Author Personas")
@@ -166,16 +165,14 @@ class BookWizard:
self.user = self._get_or_create_wizard_user()
if self.open_existing_project(): return True
elif choice == 3:
# Personas don't need a user context
self.manage_personas()
else:
return False
def create_new_project(self):
self.clear()
console.print(Panel("[bold green]🆕 New Project Setup[/bold green]"))
console.print(Panel("[bold green]New Project Setup[/bold green]"))
# 1. Ask for Concept first to guide defaults
console.print("Tell me about your story idea (or leave empty to start from scratch).")
concept = Prompt.ask("Story Concept")
@@ -217,7 +214,7 @@ class BookWizard:
while True:
self.clear()
console.print(Panel("[bold green]🤖 AI Suggestions[/bold green]"))
console.print(Panel("[bold green]AI Suggestions[/bold green]"))
grid = Table.grid(padding=(0, 2))
grid.add_column(style="bold cyan")
@@ -238,7 +235,6 @@ class BookWizard:
grid.add_row("Length:", len_label)
grid.add_row("Est. Chapters:", str(suggestions.get('estimated_chapters', 'N/A')))
grid.add_row("Est. Words:", str(suggestions.get('estimated_word_count', 'N/A')))
grid.add_row("Tropes:", get_str('tropes'))
grid.add_row("POV:", get_str('pov_style'))
grid.add_row("Time:", get_str('time_period'))
@@ -272,7 +268,6 @@ class BookWizard:
new_sugg = self.ask_gemini_json(refine_prompt)
if new_sugg: suggestions = new_sugg
# 2. Select Type (with AI default)
default_type = "2" if suggestions.get('is_series') else "1"
console.print("1. Standalone Book")
@@ -288,14 +283,13 @@ class BookWizard:
return True
def open_existing_project(self):
# Query projects from the database for the wizard user
projects = Project.query.filter_by(user_id=self.user.id).order_by(Project.name).all()
if not projects:
console.print(f"[red]No projects found for user '{self.user.username}'. Create one first.[/red]")
Prompt.ask("Press Enter to continue...")
return False
console.print("\n[bold cyan]📂 Select Project[/bold cyan]")
console.print("\n[bold cyan]Select Project[/bold cyan]")
for i, p in enumerate(projects):
console.print(f"[{i+1}] {p.name}")
@@ -325,9 +319,8 @@ class BookWizard:
def configure_details(self, suggestions=None, concept="", is_series=False):
if suggestions is None: suggestions = {}
console.print("\n[bold blue]📝 Project Details[/bold blue]")
console.print("\n[bold blue]Project Details[/bold blue]")
# Simplified Persona Selection (Skip creation)
personas = {}
if os.path.exists(config.PERSONAS_FILE):
try:
@@ -362,10 +355,8 @@ class BookWizard:
if def_len not in config.LENGTH_DEFINITIONS: def_len = "4"
len_choice = Prompt.ask("Select Target Length", choices=list(config.LENGTH_DEFINITIONS.keys()), default=def_len)
# Create a copy so we don't modify the global definition
settings = config.LENGTH_DEFINITIONS[len_choice].copy()
# AI Defaults
def_chapters = suggestions.get('estimated_chapters', settings['chapters'])
def_words = suggestions.get('estimated_word_count', settings['words'])
def_prologue = suggestions.get('include_prologue', False)
@@ -376,8 +367,7 @@ class BookWizard:
settings['include_prologue'] = Confirm.ask("Include Prologue?", default=def_prologue)
settings['include_epilogue'] = Confirm.ask("Include Epilogue?", default=def_epilogue)
# --- GENRE STANDARD CHECK ---
# Parse current word count selection
# Genre Standard Check
w_str = str(settings.get('words', '0')).replace(',', '').replace('+', '').lower()
avg_words = 0
if '-' in w_str:
@@ -388,7 +378,6 @@ class BookWizard:
try: avg_words = int(w_str.replace('k', '000'))
except: pass
# Define rough standards
std_target = 0
g_lower = genre.lower()
if "fantasy" in g_lower or "sci-fi" in g_lower or "space" in g_lower or "epic" in g_lower: std_target = 100000
@@ -397,9 +386,8 @@ class BookWizard:
elif "young adult" in g_lower or "ya" in g_lower: std_target = 60000
if std_target > 0 and avg_words > 0:
# If difference is > 25%, warn user
if abs(std_target - avg_words) / std_target > 0.25:
console.print(f"\n[bold yellow]⚠️ Genre Advisory:[/bold yellow] Standard length for {genre} is approx {std_target:,} words.")
console.print(f"\n[bold yellow]Genre Advisory:[/bold yellow] Standard length for {genre} is approx {std_target:,} words.")
if Confirm.ask(f"Update target to {std_target:,} words?", default=True):
settings['words'] = f"{std_target:,}"
@@ -409,15 +397,11 @@ class BookWizard:
tropes_input = Prompt.ask("Tropes/Themes (comma sep)", default=def_tropes)
sel_tropes = [x.strip() for x in tropes_input.split(',')] if tropes_input else []
# TITLE
# If series, this is Series Title. If book, Book Title.
title = Prompt.ask("Book Title (Leave empty for AI)", default=suggestions.get('title', ""))
# PROJECT NAME
default_proj = utils.sanitize_filename(title) if title else "New_Project"
self.project_name = Prompt.ask("Project Name (Folder)", default=default_proj)
# Create Project in DB and set path
user_dir = os.path.join(config.DATA_DIR, "users", str(self.user.id))
if not os.path.exists(user_dir): os.makedirs(user_dir)
@@ -433,12 +417,10 @@ class BookWizard:
console.print("\n[italic]Note: Tone describes the overall mood or atmosphere (e.g. Dark, Whimsical, Cynical, Hopeful).[/italic]")
tone = Prompt.ask("Tone", default=suggestions.get('tone', "Balanced"))
# POV SETTINGS
pov_style = Prompt.ask("POV Style (e.g. 'Third Person Limited', 'First Person')", default=suggestions.get('pov_style', "Third Person Limited"))
pov_chars_input = Prompt.ask("POV Characters (comma sep, leave empty if single protagonist)", default="")
pov_chars = [x.strip() for x in pov_chars_input.split(',')] if pov_chars_input else []
# ADVANCED STYLE
tense = Prompt.ask("Narrative Tense (e.g. 'Past', 'Present')", default=suggestions.get('narrative_tense', "Past"))
console.print("\n[bold]Content Guidelines[/bold]")
@@ -451,7 +433,6 @@ class BookWizard:
console.print("\n[bold]Formatting & World Rules[/bold]")
time_period = Prompt.ask("Time Period/Tech (e.g. 'Modern', '1990s', 'No Cellphones')", default=suggestions.get('time_period', "Modern"))
# Visuals
orientation = Prompt.ask("Page Orientation", choices=["Portrait", "Landscape", "Square"], default=suggestions.get('page_orientation', "Portrait"))
console.print("[italic]Define formatting rules (e.g. 'Chapter Headers: POV + Title', 'Text Messages: Italic').[/italic]")
@@ -459,7 +440,6 @@ class BookWizard:
fmt_input = Prompt.ask("Formatting Rules (comma sep)", default=def_fmt)
fmt_rules = [x.strip() for x in fmt_input.split(',')] if fmt_input else []
# Update book_metadata with new fields
style_data = {
"tone": tone, "tropes": sel_tropes,
"pov_style": pov_style, "pov_characters": pov_chars,
@@ -481,7 +461,6 @@ class BookWizard:
"style": style_data
}
# Initialize Books List
self.data['books'] = []
if is_series:
count = IntPrompt.ask("How many books in the series?", default=3)
@@ -501,7 +480,7 @@ class BookWizard:
})
def enrich_blueprint(self):
console.print("\n[bold yellow]Generating full Book Bible (Characters, Plot, etc.)...[/bold yellow]")
console.print("\n[bold yellow]Generating full Book Bible (Characters, Plot, etc.)...[/bold yellow]")
prompt = f"""
ROLE: Creative Director
@@ -527,11 +506,9 @@ class BookWizard:
if new_data:
if 'characters' in new_data:
self.data['characters'] = new_data['characters']
# Filter defaults
self.data['characters'] = [c for c in self.data['characters'] if c.get('name') and c.get('name').lower() not in ['name', 'character name', 'role', 'protagonist', 'unknown']]
if 'books' in new_data:
# Merge book data carefully
ai_books = {b.get('book_number'): b for b in new_data['books']}
for i, book in enumerate(self.data['books']):
b_num = book.get('book_number', i+1)
@@ -548,7 +525,6 @@ class BookWizard:
length = meta.get('length_settings', {})
style = meta.get('style', {})
# Metadata Grid
grid = Table.grid(padding=(0, 2))
grid.add_column(style="bold cyan")
grid.add_column()
@@ -558,37 +534,25 @@ class BookWizard:
grid.add_row("Genre:", meta.get('genre', 'N/A'))
grid.add_row("Audience:", meta.get('target_audience', 'N/A'))
# Dynamic Style Display
# Define explicit order for common fields
ordered_keys = [
"tone", "pov_style", "pov_characters",
"tense", "spice", "violence", "language", "dialogue_style", "time_period", "page_orientation",
"tropes"
]
defaults = {
"tone": "Balanced",
"pov_style": "Third Person Limited",
"tense": "Past",
"spice": "Standard",
"violence": "Standard",
"language": "Standard",
"dialogue_style": "Standard",
"time_period": "Modern",
"page_orientation": "Portrait"
"tone": "Balanced", "pov_style": "Third Person Limited", "tense": "Past",
"spice": "Standard", "violence": "Standard", "language": "Standard",
"dialogue_style": "Standard", "time_period": "Modern", "page_orientation": "Portrait"
}
# 1. Show ordered keys first
for k in ordered_keys:
val = style.get(k)
if val in [None, "", "N/A"]:
val = defaults.get(k, 'N/A')
if isinstance(val, list): val = ", ".join(val)
if isinstance(val, bool): val = "Yes" if val else "No"
grid.add_row(f"{k.replace('_', ' ').title()}:", str(val))
# 2. Show remaining keys
for k, v in style.items():
if k not in ordered_keys and k != 'formatting_rules':
val = ", ".join(v) if isinstance(v, list) else str(v)
@@ -602,29 +566,25 @@ class BookWizard:
grid.add_row("Length:", len_str)
grid.add_row("Series:", "Yes" if meta.get('is_series') else "No")
console.print(Panel(grid, title="[bold blue]📖 Project Metadata[/bold blue]", expand=False))
console.print(Panel(grid, title="[bold blue]Project Metadata[/bold blue]", expand=False))
# Formatting Rules Table
fmt_rules = style.get('formatting_rules', [])
if fmt_rules:
fmt_table = Table(title="Formatting Rules", show_header=False, box=None, expand=True)
for i, r in enumerate(fmt_rules):
fmt_table.add_row(f"[bold]{i+1}.[/bold]", str(r))
console.print(Panel(fmt_table, title="[bold blue]🎨 Formatting[/bold blue]"))
console.print(Panel(fmt_table, title="[bold blue]Formatting[/bold blue]"))
# Characters Table
char_table = Table(title="👥 Characters", show_header=True, header_style="bold magenta", expand=True)
char_table = Table(title="Characters", show_header=True, header_style="bold magenta", expand=True)
char_table.add_column("Name", style="green")
char_table.add_column("Role")
char_table.add_column("Description")
for c in data.get('characters', []):
# Removed truncation to show full description
char_table.add_row(c.get('name', '-'), c.get('role', '-'), c.get('description', '-'))
console.print(char_table)
# Books List
for book in data.get('books', []):
console.print(f"\n[bold cyan]📘 Book {book.get('book_number')}: {book.get('title')}[/bold cyan]")
console.print(f"\n[bold cyan]Book {book.get('book_number')}: {book.get('title')}[/bold cyan]")
console.print(f"[italic]{book.get('manual_instruction')}[/italic]")
beats = book.get('plot_beats', [])
@@ -637,14 +597,13 @@ class BookWizard:
def refine_blueprint(self, title="Refine Blueprint"):
while True:
self.clear()
console.print(Panel(f"[bold blue]🔧 {title}[/bold blue]"))
console.print(Panel(f"[bold blue]{title}[/bold blue]"))
self.display_summary(self.data)
console.print("\n[dim](Full JSON loaded)[/dim]")
change = Prompt.ask("\n[bold green]Enter instruction to change (e.g. 'Make it darker', 'Rename Bob', 'Add a twist') or 'done'[/bold green]")
if change.lower() == 'done': break
# Inner loop for refinement
current_data = self.data
instruction = change
@@ -667,7 +626,7 @@ class BookWizard:
break
self.clear()
console.print(Panel("[bold blue]👀 Review AI Changes[/bold blue]"))
console.print(Panel("[bold blue]Review AI Changes[/bold blue]"))
self.display_summary(new_data)
feedback = Prompt.ask("\n[bold green]Is this good? (Type 'yes' to save, or enter feedback to refine)[/bold green]")
@@ -689,7 +648,7 @@ class BookWizard:
filename = os.path.join(self.project_path, "bible.json")
with open(filename, 'w') as f: json.dump(self.data, f, indent=2)
console.print(Panel(f"[bold green]Bible saved to: {filename}[/bold green]"))
console.print(Panel(f"[bold green]Bible saved to: {filename}[/bold green]"))
return filename
def manage_runs(self):
@@ -721,7 +680,6 @@ class BookWizard:
selected_run = runs[choice-1]
run_path = os.path.join(runs_dir, selected_run)
self.manage_specific_run(run_path)
def manage_specific_run(self, run_path):
@@ -729,7 +687,6 @@ class BookWizard:
self.clear()
console.print(Panel(f"[bold blue]Run: {os.path.basename(run_path)}[/bold blue]"))
# Detect sub-books (Series Run)
subdirs = sorted([d for d in os.listdir(run_path) if os.path.isdir(os.path.join(run_path, d)) and d.startswith("Book_")])
if subdirs:
@@ -768,7 +725,6 @@ class BookWizard:
choice = int(Prompt.ask("Select Action", choices=["1", "2", "3"]))
if choice == 1:
import main
bp_path = os.path.join(folder_path, "final_blueprint.json")
ms_path = os.path.join(folder_path, "manuscript.json")
@@ -777,7 +733,6 @@ class BookWizard:
with open(bp_path, 'r') as f: bp = json.load(f)
with open(ms_path, 'r') as f: ms = json.load(f)
# Check/Generate Tracking
events_path = os.path.join(folder_path, "tracking_events.json")
chars_path = os.path.join(folder_path, "tracking_characters.json")
tracking = {"events": [], "characters": {}}
@@ -785,10 +740,9 @@ class BookWizard:
if os.path.exists(events_path): tracking['events'] = utils.load_json(events_path)
if os.path.exists(chars_path): tracking['characters'] = utils.load_json(chars_path)
main.ai.init_models()
ai_setup.init_models()
if not tracking['events'] and not tracking['characters']:
# Fallback: Use Blueprint data
console.print("[yellow]Tracking missing. Populating from Blueprint...[/yellow]")
tracking['events'] = bp.get('plot_beats', [])
tracking['characters'] = {}
@@ -802,8 +756,8 @@ class BookWizard:
with open(events_path, 'w') as f: json.dump(tracking['events'], f, indent=2)
with open(chars_path, 'w') as f: json.dump(tracking['characters'], f, indent=2)
main.marketing.generate_cover(bp, folder_path, tracking)
main.export.compile_files(bp, ms, folder_path)
marketing_cover.generate_cover(bp, folder_path, tracking)
exporter.compile_files(bp, ms, folder_path)
console.print("[green]Cover updated and EPUB recompiled![/green]")
Prompt.ask("Press Enter...")
else:
@@ -820,6 +774,7 @@ class BookWizard:
else:
os.system(f"open '{path}'")
if __name__ == "__main__":
w = BookWizard()
with app.app_context():
@@ -827,7 +782,7 @@ if __name__ == "__main__":
if w.select_mode():
while True:
w.clear()
console.print(Panel(f"[bold blue]📂 Project: {w.project_name}[/bold blue]"))
console.print(Panel(f"[bold blue]Project: {w.project_name}[/bold blue]"))
console.print("1. Edit Bible")
console.print("2. Run Book Generation")
console.print("3. Manage Runs")
@@ -842,14 +797,10 @@ if __name__ == "__main__":
elif choice == 2:
if w.load_bible():
bible_path = os.path.join(w.project_path, "bible.json")
import main
main.run_generation(bible_path, interactive=True)
run_generation(bible_path, interactive=True)
Prompt.ask("\nGeneration complete. Press Enter...")
elif choice == 3:
# Manage runs
w.manage_runs()
else:
break
else:
pass
except KeyboardInterrupt: console.print("\n[red]Cancelled.[/red]")

0
core/__init__.py Normal file
View File

View File

@@ -1,8 +1,12 @@
import os
from dotenv import load_dotenv
# Ensure .env is loaded from the script's directory (VS Code fix)
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
# __file__ is core/config.py; app root is one level up
_HERE = os.path.dirname(os.path.abspath(__file__))
BASE_DIR = os.path.dirname(_HERE)
# Ensure .env is loaded from the app root
load_dotenv(os.path.join(BASE_DIR, ".env"))
def get_clean_env(key, default=None):
val = os.getenv(key, default)
@@ -28,7 +32,6 @@ if FLASK_SECRET == "dev-secret-key-change-this":
if not API_KEY: raise ValueError("❌ CRITICAL ERROR: GEMINI_API_KEY not found.")
# --- DATA DIRECTORIES ---
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data")
PROJECTS_DIR = os.path.join(DATA_DIR, "projects")
PERSONAS_DIR = os.path.join(DATA_DIR, "personas")
@@ -36,17 +39,14 @@ PERSONAS_FILE = os.path.join(PERSONAS_DIR, "personas.json")
FONTS_DIR = os.path.join(DATA_DIR, "fonts")
# --- ENSURE DIRECTORIES EXIST ---
# Critical: Create data folders immediately to prevent DB initialization errors
for d in [DATA_DIR, PROJECTS_DIR, PERSONAS_DIR, FONTS_DIR]:
if not os.path.exists(d): os.makedirs(d, exist_ok=True)
# --- AUTHENTICATION ---
GOOGLE_CREDS = os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
if GOOGLE_CREDS:
# Resolve to absolute path relative to this config file if not absolute
if not os.path.isabs(GOOGLE_CREDS):
base = os.path.dirname(os.path.abspath(__file__))
GOOGLE_CREDS = os.path.join(base, GOOGLE_CREDS)
GOOGLE_CREDS = os.path.join(BASE_DIR, GOOGLE_CREDS)
if os.path.exists(GOOGLE_CREDS):
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = GOOGLE_CREDS

View File

@@ -2,7 +2,7 @@ import os
import json
import datetime
import time
import config
from core import config
import threading
import re
@@ -35,7 +35,6 @@ def update_progress(percent):
def clean_json(text):
text = text.replace("```json", "").replace("```", "").strip()
# Robust extraction: find first { or [ and last } or ]
start_obj = text.find('{')
start_arr = text.find('[')
if start_obj == -1 and start_arr == -1: return text
@@ -45,13 +44,11 @@ def clean_json(text):
return text[start_arr:text.rfind(']')+1]
def sanitize_filename(name):
"""Sanitizes a string to be safe for filenames."""
if not name: return "Untitled"
safe = "".join([c for c in name if c.isalnum() or c=='_']).replace(" ", "_")
return safe if safe else "Untitled"
def chapter_sort_key(ch):
"""Sort key for chapters handling integers, strings, Prologue, and Epilogue."""
num = ch.get('num', 0)
if isinstance(num, int): return num
if isinstance(num, str) and num.isdigit(): return int(num)
@@ -61,7 +58,6 @@ def chapter_sort_key(ch):
return 999
def get_sorted_book_folders(run_dir):
"""Returns a list of book folder names in a run directory, sorted numerically."""
if not os.path.exists(run_dir): return []
subdirs = [d for d in os.listdir(run_dir) if os.path.isdir(os.path.join(run_dir, d)) and d.startswith("Book_")]
def sort_key(d):
@@ -70,9 +66,7 @@ def get_sorted_book_folders(run_dir):
return 0
return sorted(subdirs, key=sort_key)
# --- SHARED UTILS ---
def log_banner(phase, title):
"""Log a visually distinct phase separator line."""
log(phase, f"{'' * 18} {title} {'' * 18}")
def log(phase, msg):
@@ -80,12 +74,10 @@ def log(phase, msg):
line = f"[{timestamp}] {phase:<15} | {msg}"
print(line)
# Write to thread-specific log file if set
if getattr(_log_context, 'log_file', None):
with open(_log_context.log_file, "a", encoding="utf-8") as f:
f.write(line + "\n")
# Trigger callback if set (e.g. for Database logging)
if getattr(_log_context, 'callback', None):
try: _log_context.callback(phase, msg)
except: pass
@@ -94,7 +86,6 @@ def load_json(path):
return json.load(open(path, 'r')) if os.path.exists(path) else None
def create_default_personas():
# Initialize empty personas file if it doesn't exist
if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR)
if not os.path.exists(config.PERSONAS_FILE):
try:
@@ -102,7 +93,6 @@ def create_default_personas():
except: pass
def get_length_presets():
"""Returns a dict mapping Label -> Settings for use in main.py"""
presets = {}
for k, v in config.LENGTH_DEFINITIONS.items():
presets[v['label']] = v
@@ -145,28 +135,19 @@ def get_latest_run_folder(base_name):
return os.path.join(base_name, runs[-1])
def update_pricing(model_name, cost_str):
"""Parses cost string from AI selection and updates cache."""
if not model_name or not cost_str or cost_str == 'N/A': return
try:
# Look for patterns like "$0.075 Input" or "$3.50/1M"
# Default to 0.0
in_cost = 0.0
out_cost = 0.0
# Extract all float-like numbers following a $ sign
prices = re.findall(r'(?:\$|USD)\s*([0-9]+\.?[0-9]*)', cost_str, re.IGNORECASE)
if len(prices) >= 2:
in_cost = float(prices[0])
out_cost = float(prices[1])
elif len(prices) == 1:
in_cost = float(prices[0])
out_cost = in_cost * 3 # Rough heuristic if only one price provided
out_cost = in_cost * 3
if in_cost > 0:
PRICING_CACHE[model_name] = {"input": in_cost, "output": out_cost}
# log("SYSTEM", f"Updated pricing for {model_name}: In=${in_cost} | Out=${out_cost}")
except:
pass
@@ -174,14 +155,12 @@ def calculate_cost(model_label, input_tokens, output_tokens, image_count=0):
cost = 0.0
m = model_label.lower()
# Check dynamic cache first
if model_label in PRICING_CACHE:
rates = PRICING_CACHE[model_label]
cost = (input_tokens / 1_000_000 * rates['input']) + (output_tokens / 1_000_000 * rates['output'])
elif 'imagen' in m or image_count > 0:
cost = (image_count * 0.04)
else:
# Fallbacks
if 'flash' in m:
cost = (input_tokens / 1_000_000 * 0.075) + (output_tokens / 1_000_000 * 0.30)
elif 'pro' in m or 'logic' in m:
@@ -203,7 +182,6 @@ def log_usage(folder, model_label, usage_metadata=None, image_count=0):
output_tokens = usage_metadata.candidates_token_count
except: pass
# Calculate Cost
cost = calculate_cost(model_label, input_tokens, output_tokens, image_count)
entry = {
@@ -227,7 +205,6 @@ def log_usage(folder, model_label, usage_metadata=None, image_count=0):
data["log"].append(entry)
# Recalculate totals
t_in = sum(x.get('input_tokens', 0) for x in data["log"])
t_out = sum(x.get('output_tokens', 0) for x in data["log"])
t_img = sum(x.get('images', 0) for x in data["log"])
@@ -237,7 +214,6 @@ def log_usage(folder, model_label, usage_metadata=None, image_count=0):
if 'cost' in x:
total_cost += x['cost']
else:
# Fallback calculation for old logs without explicit cost field
c = 0.0
mx = x.get('model', '').lower()
ix = x.get('input_tokens', 0)

View File

@@ -18,11 +18,14 @@ services:
# --- DEVELOPMENT (Code Sync) ---
# UNCOMMENT these lines only if you are developing and want to see changes instantly.
# For production/deployment, keep them commented out so the container uses the built image code.
# - ./modules:/app/modules
# - ./core:/app/core
# - ./ai:/app/ai
# - ./story:/app/story
# - ./marketing:/app/marketing
# - ./export:/app/export
# - ./web:/app/web
# - ./cli:/app/cli
# - ./templates:/app/templates
# - ./main.py:/app/main.py
# - ./wizard.py:/app/wizard.py
# - ./config.py:/app/config.py
environment:
- PYTHONUNBUFFERED=1
- GOOGLE_APPLICATION_CREDENTIALS=/app/credentials.json

0
export/__init__.py Normal file
View File

View File

@@ -2,7 +2,8 @@ import os
import markdown
from docx import Document
from ebooklib import epub
from . import utils
from core import utils
def create_readme(folder, bp):
meta = bp['book_metadata']
@@ -10,6 +11,7 @@ def create_readme(folder, bp):
content = f"""# {meta['title']}\n**Generated by BookApp**\n\n## Stats Used\n- **Type:** {ls.get('label', 'Custom')}\n- **Planned Chapters:** {ls['chapters']}\n- **Logic Depth:** {ls['depth']}\n- **Target Words:** {ls.get('words', 'Unknown')}"""
with open(os.path.join(folder, "README.md"), "w") as f: f.write(content)
def compile_files(bp, ms, folder):
utils.log("SYSTEM", "Compiling EPUB and DOCX...")
meta = bp.get('book_metadata', {})
@@ -23,17 +25,14 @@ def compile_files(bp, ms, folder):
doc = Document(); doc.add_heading(title, 0)
book = epub.EpubBook(); book.set_title(title); spine = ['nav']
# Add Cover if exists
cover_path = os.path.join(folder, "cover.png")
if os.path.exists(cover_path):
with open(cover_path, 'rb') as f:
book.set_cover("cover.png", f.read())
# Ensure manuscript is sorted correctly before compiling
ms.sort(key=utils.chapter_sort_key)
for c in ms:
# Determine filename/type
num_str = str(c['num']).lower()
if num_str == '0' or 'prologue' in num_str:
filename = "prologue.xhtml"
@@ -45,7 +44,6 @@ def compile_files(bp, ms, folder):
filename = f"ch_{c['num']}.xhtml"
default_header = f"Ch {c['num']}: {c['title']}"
# Check for AI-generated header in content
content = c['content'].strip()
clean_content = content.replace("```markdown", "").replace("```", "").strip()
lines = clean_content.split('\n')

0
marketing/__init__.py Normal file
View File

7
marketing/assets.py Normal file
View File

@@ -0,0 +1,7 @@
from marketing.blurb import generate_blurb
from marketing.cover import generate_cover
def create_marketing_assets(bp, folder, tracking=None, interactive=False):
generate_blurb(bp, folder)
generate_cover(bp, folder, tracking, interactive=interactive)

51
marketing/blurb.py Normal file
View File

@@ -0,0 +1,51 @@
import os
import json
from core import utils
from ai import models as ai_models
def generate_blurb(bp, folder):
utils.log("MARKETING", "Generating blurb...")
meta = bp.get('book_metadata', {})
beats = bp.get('plot_beats', [])
beats_text = "\n".join(f" - {b}" for b in beats[:6]) if beats else " - (no beats provided)"
chars = bp.get('characters', [])
protagonist = next((c for c in chars if 'protagonist' in c.get('role', '').lower()), None)
protagonist_desc = f"{protagonist['name']}{protagonist.get('description', '')}" if protagonist else "the protagonist"
prompt = f"""
ROLE: Marketing Copywriter
TASK: Write a compelling back-cover blurb for a {meta.get('genre', 'fiction')} novel.
BOOK DETAILS:
- TITLE: {meta.get('title')}
- GENRE: {meta.get('genre')}
- AUDIENCE: {meta.get('target_audience', 'General')}
- PROTAGONIST: {protagonist_desc}
- LOGLINE: {bp.get('manual_instruction', '(none)')}
- KEY PLOT BEATS:
{beats_text}
BLURB STRUCTURE:
1. HOOK (1-2 sentences): Open with the protagonist's world and the inciting disruption. Make it urgent.
2. STAKES (2-3 sentences): Raise the central conflict. What does the protagonist stand to lose?
3. TENSION (1-2 sentences): Hint at the impossible choice or escalating danger without revealing the resolution.
4. HOOK CLOSE (1 sentence): End with a tantalising question or statement that demands the reader open the book.
RULES:
- 150-200 words total.
- DO NOT reveal the ending or resolution.
- Match the genre's marketing tone ({meta.get('genre', 'fiction')}: e.g. thriller = urgent/terse, romance = emotionally charged, fantasy = epic/wondrous, horror = dread-laden).
- Use present tense for the blurb voice.
- No "Blurb:", no title prefix, no labels — marketing copy only.
"""
try:
response = ai_models.model_writer.generate_content(prompt)
utils.log_usage(folder, ai_models.model_writer.name, response.usage_metadata)
blurb = response.text
with open(os.path.join(folder, "blurb.txt"), "w") as f: f.write(blurb)
with open(os.path.join(folder, "back_cover.txt"), "w") as f: f.write(blurb)
except:
utils.log("MARKETING", "Failed to generate blurb.")

View File

@@ -4,11 +4,9 @@ import json
import shutil
import textwrap
import subprocess
import requests
from . import utils
import config
from modules import ai
from rich.prompt import Confirm
from core import utils
from ai import models as ai_models
from marketing.fonts import download_font
try:
from PIL import Image, ImageDraw, ImageFont, ImageStat
@@ -16,59 +14,6 @@ try:
except ImportError:
HAS_PIL = False
def download_font(font_name):
"""Attempts to download a Google Font from GitHub."""
if not font_name: font_name = "Roboto"
if not os.path.exists(config.FONTS_DIR): os.makedirs(config.FONTS_DIR)
# Handle CSS-style lists (e.g. "Roboto, sans-serif")
if "," in font_name: font_name = font_name.split(",")[0].strip()
# Handle filenames provided by AI
if font_name.lower().endswith(('.ttf', '.otf')):
font_name = os.path.splitext(font_name)[0]
font_name = font_name.strip().strip("'").strip('"')
for suffix in ["-Regular", " Regular", " regular", "Regular", " Bold", " Italic"]:
if font_name.endswith(suffix):
font_name = font_name[:-len(suffix)]
font_name = font_name.strip()
clean_name = font_name.replace(" ", "").lower()
font_filename = f"{clean_name}.ttf"
font_path = os.path.join(config.FONTS_DIR, font_filename)
if os.path.exists(font_path) and os.path.getsize(font_path) > 1000:
utils.log("ASSETS", f"Using cached font: {font_path}")
return font_path
utils.log("ASSETS", f"Downloading font: {font_name}...")
compact_name = font_name.replace(" ", "")
title_compact = "".join(x.title() for x in font_name.split())
patterns = [
f"static/{title_compact}-Regular.ttf", f"{title_compact}-Regular.ttf",
f"{title_compact}[wght].ttf", f"{title_compact}[wdth,wght].ttf",
f"static/{compact_name}-Regular.ttf", f"{compact_name}-Regular.ttf",
f"{title_compact}-Regular.otf",
]
headers = {"User-Agent": "Mozilla/5.0 (BookApp/1.0)"}
for license_type in ["ofl", "apache", "ufl"]:
base_url = f"https://github.com/google/fonts/raw/main/{license_type}/{clean_name}"
for pattern in patterns:
try:
r = requests.get(f"{base_url}/{pattern}", headers=headers, timeout=5)
if r.status_code == 200 and len(r.content) > 1000:
with open(font_path, 'wb') as f: f.write(r.content)
utils.log("ASSETS", f"✅ Downloaded {font_name} to {font_path}")
return font_path
except Exception: continue
if clean_name != "roboto":
utils.log("ASSETS", f"⚠️ Font '{font_name}' not found. Falling back to Roboto.")
return download_font("Roboto")
return None
def evaluate_image_quality(image_path, prompt, model, folder=None):
if not HAS_PIL: return None, "PIL not installed"
@@ -86,53 +31,6 @@ def evaluate_image_quality(image_path, prompt, model, folder=None):
return data.get('score'), data.get('reason')
except Exception as e: return None, str(e)
def generate_blurb(bp, folder):
utils.log("MARKETING", "Generating blurb...")
meta = bp.get('book_metadata', {})
# Format beats as a readable list, not raw JSON
beats = bp.get('plot_beats', [])
beats_text = "\n".join(f" - {b}" for b in beats[:6]) if beats else " - (no beats provided)"
# Format protagonist for the blurb
chars = bp.get('characters', [])
protagonist = next((c for c in chars if 'protagonist' in c.get('role', '').lower()), None)
protagonist_desc = f"{protagonist['name']}{protagonist.get('description', '')}" if protagonist else "the protagonist"
prompt = f"""
ROLE: Marketing Copywriter
TASK: Write a compelling back-cover blurb for a {meta.get('genre', 'fiction')} novel.
BOOK DETAILS:
- TITLE: {meta.get('title')}
- GENRE: {meta.get('genre')}
- AUDIENCE: {meta.get('target_audience', 'General')}
- PROTAGONIST: {protagonist_desc}
- LOGLINE: {bp.get('manual_instruction', '(none)')}
- KEY PLOT BEATS:
{beats_text}
BLURB STRUCTURE:
1. HOOK (1-2 sentences): Open with the protagonist's world and the inciting disruption. Make it urgent.
2. STAKES (2-3 sentences): Raise the central conflict. What does the protagonist stand to lose?
3. TENSION (1-2 sentences): Hint at the impossible choice or escalating danger without revealing the resolution.
4. HOOK CLOSE (1 sentence): End with a tantalising question or statement that demands the reader open the book.
RULES:
- 150-200 words total.
- DO NOT reveal the ending or resolution.
- Match the genre's marketing tone ({meta.get('genre', 'fiction')}: e.g. thriller = urgent/terse, romance = emotionally charged, fantasy = epic/wondrous, horror = dread-laden).
- Use present tense for the blurb voice.
- No "Blurb:", no title prefix, no labels marketing copy only.
"""
try:
response = ai.model_writer.generate_content(prompt)
utils.log_usage(folder, ai.model_writer.name, response.usage_metadata)
blurb = response.text
with open(os.path.join(folder, "blurb.txt"), "w") as f: f.write(blurb)
with open(os.path.join(folder, "back_cover.txt"), "w") as f: f.write(blurb)
except:
utils.log("MARKETING", "Failed to generate blurb.")
def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
if not HAS_PIL:
@@ -141,7 +39,6 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
utils.log("MARKETING", "Generating cover...")
meta = bp.get('book_metadata', {})
series = bp.get('series_metadata', {})
orientation = meta.get('style', {}).get('page_orientation', 'Portrait')
ar = "3:4"
@@ -156,11 +53,9 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
if 'characters' in tracking:
visual_context += f"Character Appearances: {json.dumps(tracking['characters'])}\n"
# Feedback Analysis
regenerate_image = True
design_instruction = ""
# If existing art exists and no feedback provided, preserve it (Keep Cover feature)
if os.path.exists(os.path.join(folder, "cover_art.png")) and not feedback:
regenerate_image = False
@@ -179,8 +74,8 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
OUTPUT_FORMAT (JSON): {{ "action": "REGENERATE_LAYOUT" or "REGENERATE_IMAGE", "instruction": "Specific instruction for Art Director" }}
"""
try:
resp = ai.model_logic.generate_content(analysis_prompt)
utils.log_usage(folder, ai.model_logic.name, resp.usage_metadata)
resp = ai_models.model_logic.generate_content(analysis_prompt)
utils.log_usage(folder, ai_models.model_logic.name, resp.usage_metadata)
decision = json.loads(utils.clean_json(resp.text))
if decision.get('action') == 'REGENERATE_LAYOUT':
regenerate_image = False
@@ -191,7 +86,6 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
genre = meta.get('genre', 'Fiction')
tone = meta.get('style', {}).get('tone', 'Balanced')
# Map genre to visual style suggestion
genre_style_map = {
'thriller': 'dark, cinematic, high-contrast photography style',
'mystery': 'moody, atmospheric, noir-inspired painting',
@@ -237,19 +131,17 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
}}
"""
try:
response = ai.model_artist.generate_content(design_prompt)
utils.log_usage(folder, ai.model_artist.name, response.usage_metadata)
response = ai_models.model_artist.generate_content(design_prompt)
utils.log_usage(folder, ai_models.model_artist.name, response.usage_metadata)
design = json.loads(utils.clean_json(response.text))
bg_color = design.get('primary_color', '#252570')
text_color = design.get('text_color', '#FFFFFF')
art_prompt = design.get('art_prompt', f"Cover art for {meta.get('title')}")
with open(os.path.join(folder, "cover_art_prompt.txt"), "w") as f:
f.write(art_prompt)
img = None
image_generated = False
width, height = 600, 900
best_img_score = 0
@@ -260,23 +152,22 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
for i in range(1, MAX_IMG_ATTEMPTS + 1):
utils.log("MARKETING", f"Generating cover art (Attempt {i}/{MAX_IMG_ATTEMPTS})...")
try:
if not ai.model_image: raise ImportError("No Image Generation Model available.")
if not ai_models.model_image: raise ImportError("No Image Generation Model available.")
status = "success"
try:
result = ai.model_image.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar)
result = ai_models.model_image.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar)
except Exception as e:
err_lower = str(e).lower()
# Try fast imagen variant before falling back to legacy
if ai.HAS_VERTEX and ("resource" in err_lower or "quota" in err_lower):
if ai_models.HAS_VERTEX and ("resource" in err_lower or "quota" in err_lower):
try:
utils.log("MARKETING", "⚠️ Imagen 3 failed. Trying Imagen 3 Fast...")
fb_model = ai.VertexImageModel.from_pretrained("imagen-3.0-fast-generate-001")
fb_model = ai_models.VertexImageModel.from_pretrained("imagen-3.0-fast-generate-001")
result = fb_model.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar)
status = "success_fast"
except Exception:
utils.log("MARKETING", "⚠️ Imagen 3 Fast failed. Trying Imagen 2...")
fb_model = ai.VertexImageModel.from_pretrained("imagegeneration@006")
fb_model = ai_models.VertexImageModel.from_pretrained("imagegeneration@006")
result = fb_model.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar)
status = "success_fallback"
else:
@@ -297,7 +188,7 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
f"Score 1-10. Deduct 3 points if any text/watermarks are visible. "
f"Deduct 2 if the image is blurry or has deformed anatomy."
)
score, critique = evaluate_image_quality(attempt_path, cover_eval_criteria, ai.model_writer, folder)
score, critique = evaluate_image_quality(attempt_path, cover_eval_criteria, ai_models.model_writer, folder)
if score is None: score = 0
utils.log("MARKETING", f" -> Image Score: {score}/10. Critique: {critique}")
@@ -310,6 +201,7 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
else: subprocess.call(('xdg-open', attempt_path))
except: pass
from rich.prompt import Confirm
if Confirm.ask(f"Accept cover attempt {i} (Score: {score})?", default=True):
best_img_path = attempt_path
break
@@ -317,12 +209,10 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
utils.log("MARKETING", "User rejected cover. Retrying...")
continue
# Only keep as best if score meets minimum quality bar
if score >= 5 and score > best_img_score:
best_img_score = score
best_img_path = attempt_path
elif best_img_path is None and score > 0:
# Accept even low-quality image if we have nothing else
best_img_score = score
best_img_path = attempt_path
@@ -330,7 +220,6 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
utils.log("MARKETING", " -> High quality image accepted.")
break
# Refine prompt based on critique keywords
prompt_additions = []
critique_lower = critique.lower() if critique else ""
if "scar" in critique_lower or "deform" in critique_lower:
@@ -351,20 +240,17 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
if best_img_path != final_art_path:
shutil.copy(best_img_path, final_art_path)
img = Image.open(final_art_path).resize((width, height)).convert("RGB")
image_generated = True
else:
utils.log("MARKETING", "Falling back to solid color cover.")
img = Image.new('RGB', (width, height), color=bg_color)
utils.log_image_attempt(folder, "cover", art_prompt, "cover.png", "fallback_solid")
else:
# Load existing art
final_art_path = os.path.join(folder, "cover_art.png")
if os.path.exists(final_art_path):
utils.log("MARKETING", "Using existing cover art (Layout update only).")
img = Image.open(final_art_path).resize((width, height)).convert("RGB")
else:
utils.log("MARKETING", "Existing art not found. Forcing regeneration.")
# Fallback to solid color if we were supposed to reuse but couldn't find it
img = Image.new('RGB', (width, height), color=bg_color)
font_path = download_font(design.get('font_name') or 'Arial')
@@ -398,8 +284,8 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
for attempt in range(1, 6):
utils.log("MARKETING", f"Designing text layout (Attempt {attempt}/5)...")
try:
response = ai.model_writer.generate_content([layout_prompt, img])
utils.log_usage(folder, ai.model_writer.name, response.usage_metadata)
response = ai_models.model_writer.generate_content([layout_prompt, img])
utils.log_usage(folder, ai_models.model_writer.name, response.usage_metadata)
layout = json.loads(utils.clean_json(response.text))
if isinstance(layout, list): layout = layout[0] if layout else {}
except Exception as e:
@@ -438,11 +324,11 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
total_h = sum(line_heights)
current_y = y - (total_h // 2)
for i, line in enumerate(lines):
for idx, line in enumerate(lines):
bbox = draw.textbbox((0, 0), line, font=font)
lx = x - ((bbox[2] - bbox[0]) / 2)
draw.text((lx, current_y), line, font=font, fill=color)
current_y += line_heights[i]
current_y += line_heights[idx]
draw_element('title', meta.get('title'))
draw_element('author', meta.get('author'))
@@ -450,7 +336,6 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
attempt_path = os.path.join(folder, f"cover_layout_attempt_{attempt}.png")
img_copy.save(attempt_path)
# Evaluate Layout
eval_prompt = f"""
Analyze the text layout for the book title '{meta.get('title')}'.
CHECKLIST:
@@ -458,7 +343,7 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
2. Is the contrast sufficient?
3. Does it look professional?
"""
score, critique = evaluate_image_quality(attempt_path, eval_prompt, ai.model_writer, folder)
score, critique = evaluate_image_quality(attempt_path, eval_prompt, ai_models.model_writer, folder)
if score is None: score = 0
utils.log("MARKETING", f" -> Layout Score: {score}/10. Critique: {critique}")
@@ -478,7 +363,3 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
except Exception as e:
utils.log("MARKETING", f"Cover generation failed: {e}")
def create_marketing_assets(bp, folder, tracking=None, interactive=False):
generate_blurb(bp, folder)
generate_cover(bp, folder, tracking, interactive=interactive)

55
marketing/fonts.py Normal file
View File

@@ -0,0 +1,55 @@
import os
import requests
from core import config, utils
def download_font(font_name):
if not font_name: font_name = "Roboto"
if not os.path.exists(config.FONTS_DIR): os.makedirs(config.FONTS_DIR)
if "," in font_name: font_name = font_name.split(",")[0].strip()
if font_name.lower().endswith(('.ttf', '.otf')):
font_name = os.path.splitext(font_name)[0]
font_name = font_name.strip().strip("'").strip('"')
for suffix in ["-Regular", " Regular", " regular", "Regular", " Bold", " Italic"]:
if font_name.endswith(suffix):
font_name = font_name[:-len(suffix)]
font_name = font_name.strip()
clean_name = font_name.replace(" ", "").lower()
font_filename = f"{clean_name}.ttf"
font_path = os.path.join(config.FONTS_DIR, font_filename)
if os.path.exists(font_path) and os.path.getsize(font_path) > 1000:
utils.log("ASSETS", f"Using cached font: {font_path}")
return font_path
utils.log("ASSETS", f"Downloading font: {font_name}...")
compact_name = font_name.replace(" ", "")
title_compact = "".join(x.title() for x in font_name.split())
patterns = [
f"static/{title_compact}-Regular.ttf", f"{title_compact}-Regular.ttf",
f"{title_compact}[wght].ttf", f"{title_compact}[wdth,wght].ttf",
f"static/{compact_name}-Regular.ttf", f"{compact_name}-Regular.ttf",
f"{title_compact}-Regular.otf",
]
headers = {"User-Agent": "Mozilla/5.0 (BookApp/1.0)"}
for license_type in ["ofl", "apache", "ufl"]:
base_url = f"https://github.com/google/fonts/raw/main/{license_type}/{clean_name}"
for pattern in patterns:
try:
r = requests.get(f"{base_url}/{pattern}", headers=headers, timeout=5)
if r.status_code == 200 and len(r.content) > 1000:
with open(font_path, 'wb') as f: f.write(r.content)
utils.log("ASSETS", f"✅ Downloaded {font_name} to {font_path}")
return font_path
except Exception: continue
if clean_name != "roboto":
utils.log("ASSETS", f"⚠️ Font '{font_name}' not found. Falling back to Roboto.")
return download_font("Roboto")
return None

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

0
story/__init__.py Normal file
View File

144
story/bible_tracker.py Normal file
View File

@@ -0,0 +1,144 @@
import json
from core import utils
from ai import models as ai_models
def merge_selected_changes(original, draft, selected_keys):
def sort_key(k):
return [int(p) if p.isdigit() else p for p in k.split('.')]
selected_keys.sort(key=sort_key)
for key in selected_keys:
parts = key.split('.')
if parts[0] == 'meta' and len(parts) == 2:
field = parts[1]
if field == 'tone':
original['project_metadata']['style']['tone'] = draft['project_metadata']['style']['tone']
elif field in original['project_metadata']:
original['project_metadata'][field] = draft['project_metadata'][field]
elif parts[0] == 'char' and len(parts) >= 2:
idx = int(parts[1])
if idx < len(draft['characters']):
if idx < len(original['characters']):
original['characters'][idx] = draft['characters'][idx]
else:
original['characters'].append(draft['characters'][idx])
elif parts[0] == 'book' and len(parts) >= 2:
book_num = int(parts[1])
orig_book = next((b for b in original['books'] if b['book_number'] == book_num), None)
draft_book = next((b for b in draft['books'] if b['book_number'] == book_num), None)
if draft_book:
if not orig_book:
original['books'].append(draft_book)
original['books'].sort(key=lambda x: x.get('book_number', 999))
continue
if len(parts) == 2:
orig_book['title'] = draft_book['title']
orig_book['manual_instruction'] = draft_book['manual_instruction']
elif len(parts) == 4 and parts[2] == 'beat':
beat_idx = int(parts[3])
if beat_idx < len(draft_book['plot_beats']):
while len(orig_book['plot_beats']) <= beat_idx:
orig_book['plot_beats'].append("")
orig_book['plot_beats'][beat_idx] = draft_book['plot_beats'][beat_idx]
return original
def filter_characters(chars):
blacklist = ['name', 'character name', 'role', 'protagonist', 'antagonist', 'love interest', 'unknown', 'tbd', 'todo', 'hero', 'villain', 'main character', 'side character']
return [c for c in chars if c.get('name') and c.get('name').lower().strip() not in blacklist]
def update_tracking(folder, chapter_num, chapter_text, current_tracking):
utils.log("TRACKER", f"Updating world state & character visuals for Ch {chapter_num}...")
prompt = f"""
ROLE: Continuity Tracker
TASK: Update the Story Bible based on the new chapter.
INPUT_TRACKING:
{json.dumps(current_tracking)}
NEW_TEXT:
{chapter_text[:20000]}
OPERATIONS:
1. EVENTS: Append 1-3 key plot points to 'events'.
2. CHARACTERS: Update 'descriptors', 'likes_dislikes', 'speech_style', 'last_worn', 'major_events'.
- "descriptors": List of strings. Add PERMANENT physical traits (height, hair, eyes), specific items (jewelry, weapons). Avoid duplicates.
- "likes_dislikes": List of strings. Add specific preferences, likes, or dislikes mentioned (e.g., "Hates coffee", "Loves jazz").
- "speech_style": String. Describe how they speak (e.g. "Formal, no contractions", "Uses slang", "Stutters", "Short sentences").
- "last_worn": String. Update if specific clothing is described. IMPORTANT: If a significant time jump occurred (e.g. next day) and no new clothing is described, reset this to "Unknown".
- "major_events": List of strings. Log significant life-altering events occurring in THIS chapter (e.g. "Lost an arm", "Married", "Betrayed by X").
3. WARNINGS: Append new 'content_warnings'.
OUTPUT_FORMAT (JSON): Return the updated tracking object structure.
"""
try:
response = ai_models.model_logic.generate_content(prompt)
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
new_data = json.loads(utils.clean_json(response.text))
return new_data
except Exception as e:
utils.log("TRACKER", f"Failed to update tracking: {e}")
return current_tracking
def harvest_metadata(bp, folder, full_manuscript):
utils.log("HARVESTER", "Scanning for new characters...")
full_text = "\n".join([c.get('content', '') for c in full_manuscript])[:500000]
prompt = f"""
ROLE: Data Extractor
TASK: Identify NEW significant characters.
INPUT_TEXT:
{full_text}
KNOWN_CHARACTERS: {json.dumps(bp['characters'])}
OUTPUT_FORMAT (JSON): {{ "new_characters": [{{ "name": "String", "role": "String", "description": "String" }}] }}
"""
try:
response = ai_models.model_logic.generate_content(prompt)
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
new_chars = json.loads(utils.clean_json(response.text)).get('new_characters', [])
if new_chars:
valid_chars = filter_characters(new_chars)
if valid_chars:
utils.log("HARVESTER", f"Found {len(valid_chars)} new chars.")
bp['characters'].extend(valid_chars)
except: pass
return bp
def refine_bible(bible, instruction, folder):
utils.log("SYSTEM", f"Refining Bible with instruction: {instruction}")
prompt = f"""
ROLE: Senior Developmental Editor
TASK: Update the Bible JSON based on instruction.
INPUT_DATA:
- CURRENT_JSON: {json.dumps(bible)}
- INSTRUCTION: {instruction}
CONSTRAINTS:
- Maintain valid JSON structure.
- Ensure consistency.
OUTPUT_FORMAT (JSON): The full updated Bible JSON object.
"""
try:
response = ai_models.model_logic.generate_content(prompt)
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
new_data = json.loads(utils.clean_json(response.text))
return new_data
except Exception as e:
utils.log("SYSTEM", f"Refinement failed: {e}")
return None

399
story/editor.py Normal file
View File

@@ -0,0 +1,399 @@
import json
import os
from core import utils
from ai import models as ai_models
from story.style_persona import get_style_guidelines
def evaluate_chapter_quality(text, chapter_title, genre, model, folder):
guidelines = get_style_guidelines()
ai_isms = "', '".join(guidelines['ai_isms'])
fw_examples = ", ".join([f"'He {w}'" for w in guidelines['filter_words'][:5]])
word_count = len(text.split()) if text else 0
min_sugg = max(3, int(word_count / 500))
max_sugg = min_sugg + 2
suggestion_range = f"{min_sugg}-{max_sugg}"
prompt = f"""
ROLE: Senior Literary Editor
TASK: Critique chapter draft.
METADATA:
- TITLE: {chapter_title}
- GENRE: {genre}
PROHIBITED_PATTERNS:
- AI_ISMS: {ai_isms}
- FILTER_WORDS: {fw_examples}
- CLICHES: White Room, As You Know Bob, Summary Mode, Anachronisms.
- SYNTAX: Repetitive structure, Passive Voice, Adverb Reliance.
QUALITY_RUBRIC (1-10):
1. ENGAGEMENT & TENSION: Does the story grip the reader from the first line? Is there conflict or tension in every scene?
2. SCENE EXECUTION: Is the middle of the chapter fully fleshed out? Does it avoid "sagging" or summarizing key moments?
3. VOICE & TONE: Is the narrative voice distinct? Does it match the genre?
4. SENSORY IMMERSION: Does the text use sensory details effectively without being overwhelming?
5. SHOW, DON'T TELL: Are emotions shown through physical reactions and subtext?
6. CHARACTER AGENCY: Do characters drive the plot through active choices?
7. PACING: Does the chapter feel rushed? Does the ending land with impact, or does it cut off too abruptly?
8. GENRE APPROPRIATENESS: Are introductions of characters, places, items, or actions consistent with the {genre} conventions?
9. DIALOGUE AUTHENTICITY: Do characters sound distinct? Is there subtext? Avoids "on-the-nose" dialogue.
10. PLOT RELEVANCE: Does the chapter advance the plot or character arcs significantly? Avoids filler.
11. STAGING & FLOW: Do characters enter/exit physically? Do paragraphs transition logically (Action -> Reaction)?
12. PROSE DYNAMICS: Is there sentence variety? Avoids purple prose, adjective stacking, and excessive modification.
13. CLARITY & READABILITY: Is the text easy to follow? Are sentences clear and concise?
SCORING_SCALE:
- 10 (Masterpiece): Flawless, impactful, ready for print.
- 9 (Bestseller): Exceptional quality, minor style tweaks only.
- 7-8 (Professional): Good draft, solid structure, needs editing.
- 6 (Passable): Average, has issues with pacing or voice. Needs heavy refinement.
- 1-5 (Fail): Structural flaws, boring, or incoherent. Needs rewrite.
OUTPUT_FORMAT (JSON):
{{
"score": int,
"critique": "Detailed analysis of flaws, citing specific examples from the text.",
"actionable_feedback": "List of {suggestion_range} specific, ruthless instructions for the rewrite (e.g. 'Expand the middle dialogue', 'Add sensory details about the rain', 'Dramatize the argument instead of summarizing it')."
}}
"""
try:
response = model.generate_content([prompt, text[:30000]])
model_name = getattr(model, 'name', ai_models.logic_model_name)
utils.log_usage(folder, model_name, response.usage_metadata)
data = json.loads(utils.clean_json(response.text))
critique_text = data.get('critique', 'No critique provided.')
if data.get('actionable_feedback'):
critique_text += "\n\nREQUIRED FIXES:\n" + str(data.get('actionable_feedback'))
return data.get('score', 0), critique_text
except Exception as e:
return 0, f"Evaluation error: {str(e)}"
def check_pacing(bp, summary, last_chapter_text, last_chapter_data, remaining_chapters, folder):
utils.log("ARCHITECT", "Checking pacing and structure health...")
if not remaining_chapters:
return None
meta = bp.get('book_metadata', {})
prompt = f"""
ROLE: Structural Editor
TASK: Analyze pacing.
CONTEXT:
- PREVIOUS_SUMMARY: {summary[-3000:]}
- CURRENT_CHAPTER: {last_chapter_text[-2000:]}
- UPCOMING: {json.dumps([c['title'] for c in remaining_chapters[:3]])}
- REMAINING_COUNT: {len(remaining_chapters)}
LOGIC:
- IF skipped major beats -> ADD_BRIDGE
- IF covered next chapter's beats -> CUT_NEXT
- ELSE -> OK
OUTPUT_FORMAT (JSON):
{{
"status": "ok" or "add_bridge" or "cut_next",
"reason": "Explanation...",
"new_chapter": {{ "title": "...", "beats": ["..."], "pov_character": "..." }} (Required if add_bridge)
}}
"""
try:
response = ai_models.model_logic.generate_content(prompt)
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
return json.loads(utils.clean_json(response.text))
except Exception as e:
utils.log("ARCHITECT", f"Pacing check failed: {e}")
return None
def analyze_consistency(bp, manuscript, folder):
utils.log("EDITOR", "Analyzing manuscript for continuity errors...")
if not manuscript: return {"issues": ["No manuscript found."], "score": 0}
if not bp: return {"issues": ["No blueprint found."], "score": 0}
chapter_summaries = []
for ch in manuscript:
text = ch.get('content', '')
excerpt = text[:1000] + "\n...\n" + text[-1000:] if len(text) > 2000 else text
chapter_summaries.append(f"Ch {ch.get('num')}: {excerpt}")
context = "\n".join(chapter_summaries)
prompt = f"""
ROLE: Continuity Editor
TASK: Analyze book summary for plot holes.
INPUT_DATA:
- CHARACTERS: {json.dumps(bp.get('characters', []))}
- SUMMARIES:
{context}
OUTPUT_FORMAT (JSON): {{ "issues": ["Issue 1", "Issue 2"], "score": 8, "summary": "Brief overall assessment." }}
"""
try:
response = ai_models.model_logic.generate_content(prompt)
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
return json.loads(utils.clean_json(response.text))
except Exception as e:
return {"issues": [f"Analysis failed: {e}"], "score": 0, "summary": "Error during analysis."}
def rewrite_chapter_content(bp, manuscript, chapter_num, instruction, folder):
utils.log("WRITER", f"Rewriting Ch {chapter_num} with instruction: {instruction}")
target_chap = next((c for c in manuscript if str(c.get('num')) == str(chapter_num)), None)
if not target_chap: return None
prev_text = ""
prev_chap = None
if isinstance(chapter_num, int):
prev_chap = next((c for c in manuscript if c['num'] == chapter_num - 1), None)
elif str(chapter_num).lower() == "epilogue":
numbered_chaps = [c for c in manuscript if isinstance(c['num'], int)]
if numbered_chaps:
prev_chap = max(numbered_chaps, key=lambda x: x['num'])
if prev_chap:
prev_text = prev_chap.get('content', '')[-3000:]
meta = bp.get('book_metadata', {})
ad = meta.get('author_details', {})
if not ad and 'author_bio' in meta:
persona_info = meta['author_bio']
else:
persona_info = f"Name: {ad.get('name', meta.get('author', 'Unknown'))}\n"
if ad.get('bio'): persona_info += f"Style/Bio: {ad['bio']}\n"
char_visuals = ""
from core import config
tracking_path = os.path.join(folder, "tracking_characters.json")
if os.path.exists(tracking_path):
try:
tracking_chars = utils.load_json(tracking_path)
if tracking_chars:
char_visuals = "\nCHARACTER TRACKING (Visuals & Preferences):\n"
for name, data in tracking_chars.items():
desc = ", ".join(data.get('descriptors', []))
speech = data.get('speech_style', 'Unknown')
char_visuals += f"- {name}: {desc}\n * Speech: {speech}\n"
except: pass
guidelines = get_style_guidelines()
fw_list = '", "'.join(guidelines['filter_words'])
prompt = f"""
You are an expert fiction writing AI. Your task is to rewrite a specific chapter based on a user directive.
INPUT DATA:
- TITLE: {meta.get('title')}
- GENRE: {meta.get('genre')}
- TONE: {meta.get('style', {}).get('tone')}
- AUTHOR_VOICE: {persona_info}
- PREVIOUS_CONTEXT: {prev_text}
- CURRENT_DRAFT: {target_chap.get('content', '')[:5000]}
- CHARACTERS: {json.dumps(bp.get('characters', []))}
{char_visuals}
PRIMARY DIRECTIVE (USER INSTRUCTION):
{instruction}
EXECUTION RULES:
1. CONTINUITY: The new text must flow logically from PREVIOUS_CONTEXT.
2. ADHERENCE: The PRIMARY DIRECTIVE overrides any conflicting details in CURRENT_DRAFT.
3. VOICE: Strictly emulate the AUTHOR_VOICE.
4. GENRE: Enforce {meta.get('genre')} conventions. No anachronisms.
5. LOGIC: Enforce strict causality (Action -> Reaction). No teleporting characters.
PROSE OPTIMIZATION RULES (STRICT ENFORCEMENT):
- FILTER_REMOVAL: Scan for words [{fw_list}]. If found, rewrite the sentence to remove the filter and describe the sensation directly.
- SENTENCE_VARIETY: Penalize consecutive sentences starting with the same pronoun or article. Vary structure.
- SHOW_DONT_TELL: Convert internal summaries of emotion into physical actions or subtextual dialogue.
- ACTIVE_VOICE: Convert passive voice ("was [verb]ed") to active voice.
- SENSORY_ANCHORING: The first paragraph must establish the setting using at least one non-visual sense (smell, sound, touch).
- SUBTEXT: Dialogue must imply meaning rather than stating it outright.
RETURN JSON:
{{
"content": "The full chapter text in Markdown...",
"summary": "A concise summary of the chapter's events and ending state (for continuity checks)."
}}
"""
try:
response = ai_models.model_logic.generate_content(prompt)
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
try:
data = json.loads(utils.clean_json(response.text))
return data.get('content'), data.get('summary')
except:
return response.text, None
except Exception as e:
utils.log("WRITER", f"Rewrite failed: {e}")
return None, None
def check_and_propagate(bp, manuscript, changed_chap_num, folder, change_summary=None):
utils.log("WRITER", f"Checking ripple effects from Ch {changed_chap_num}...")
changed_chap = next((c for c in manuscript if c['num'] == changed_chap_num), None)
if not changed_chap: return None
if change_summary:
current_context = change_summary
else:
change_summary_prompt = f"""
ROLE: Summarizer
TASK: Summarize the key events and ending state of this chapter for continuity tracking.
TEXT:
{changed_chap.get('content', '')[:10000]}
FOCUS:
- Major plot points.
- Character status changes (injuries, items acquired, location changes).
- New information revealed.
OUTPUT: Concise text summary.
"""
try:
resp = ai_models.model_writer.generate_content(change_summary_prompt)
utils.log_usage(folder, ai_models.model_writer.name, resp.usage_metadata)
current_context = resp.text
except:
current_context = changed_chap.get('content', '')[-2000:]
original_change_context = current_context
sorted_ms = sorted(manuscript, key=utils.chapter_sort_key)
start_index = -1
for i, c in enumerate(sorted_ms):
if str(c['num']) == str(changed_chap_num):
start_index = i
break
if start_index == -1 or start_index == len(sorted_ms) - 1:
return None
changes_made = False
consecutive_no_changes = 0
potential_impact_chapters = []
for i in range(start_index + 1, len(sorted_ms)):
target_chap = sorted_ms[i]
if consecutive_no_changes >= 2:
if target_chap['num'] not in potential_impact_chapters:
future_flags = [n for n in potential_impact_chapters if isinstance(n, int) and isinstance(target_chap['num'], int) and n > target_chap['num']]
if not future_flags:
remaining_chaps = sorted_ms[i:]
if not remaining_chaps: break
utils.log("WRITER", " -> Short-term ripple dissipated. Scanning remaining chapters for long-range impacts...")
chapter_summaries = []
for rc in remaining_chaps:
text = rc.get('content', '')
excerpt = text[:500] + "\n...\n" + text[-500:] if len(text) > 1000 else text
chapter_summaries.append(f"Ch {rc['num']}: {excerpt}")
scan_prompt = f"""
ROLE: Continuity Scanner
TASK: Identify chapters impacted by a change.
CHANGE_CONTEXT:
{original_change_context}
CHAPTER_SUMMARIES:
{json.dumps(chapter_summaries)}
CRITERIA: Identify later chapters that mention items, characters, or locations involved in the Change Context.
OUTPUT_FORMAT (JSON): [Chapter_Number_Int, ...]
"""
try:
resp = ai_models.model_logic.generate_content(scan_prompt)
utils.log_usage(folder, ai_models.model_logic.name, resp.usage_metadata)
potential_impact_chapters = json.loads(utils.clean_json(resp.text))
if not isinstance(potential_impact_chapters, list): potential_impact_chapters = []
potential_impact_chapters = [int(x) for x in potential_impact_chapters if str(x).isdigit()]
except Exception as e:
utils.log("WRITER", f" -> Scan failed: {e}. Stopping.")
break
if not potential_impact_chapters:
utils.log("WRITER", " -> No long-range impacts detected. Stopping.")
break
else:
utils.log("WRITER", f" -> Detected potential impact in chapters: {potential_impact_chapters}")
if isinstance(target_chap['num'], int) and target_chap['num'] not in potential_impact_chapters:
utils.log("WRITER", f" -> Skipping Ch {target_chap['num']} (Not flagged).")
continue
utils.log("WRITER", f" -> Checking Ch {target_chap['num']} for continuity...")
chap_word_count = len(target_chap.get('content', '').split())
prompt = f"""
ROLE: Continuity Checker
TASK: Determine if a chapter contradicts a story change. If it does, rewrite it to fix the contradiction.
CHANGED_CHAPTER: {changed_chap_num}
CHANGE_SUMMARY: {current_context}
CHAPTER_TO_CHECK (Ch {target_chap['num']}):
{target_chap['content'][:12000]}
DECISION_LOGIC:
- If the chapter directly contradicts the change (references dead characters, items that no longer exist, events that didn't happen), status = REWRITE.
- If the chapter is consistent or only tangentially related, status = NO_CHANGE.
- Be conservative — only rewrite if there is a genuine contradiction.
REWRITE_RULES (apply only if REWRITE):
- Fix the specific contradiction. Preserve all other content.
- The rewritten chapter MUST be approximately {chap_word_count} words (same length as original).
- Include the chapter header formatted as Markdown H1.
- Do not add new plot points not in the original.
OUTPUT_FORMAT (JSON):
{{
"status": "NO_CHANGE" or "REWRITE",
"reason": "Brief explanation of the contradiction or why it's consistent",
"content": "Full Markdown rewritten chapter (ONLY if status is REWRITE, otherwise null)"
}}
"""
try:
response = ai_models.model_writer.generate_content(prompt)
utils.log_usage(folder, ai_models.model_writer.name, response.usage_metadata)
data = json.loads(utils.clean_json(response.text))
if data.get('status') == 'NO_CHANGE':
utils.log("WRITER", f" -> Ch {target_chap['num']} is consistent.")
current_context = f"Ch {target_chap['num']} Summary: " + target_chap.get('content', '')[-2000:]
consecutive_no_changes += 1
elif data.get('status') == 'REWRITE' and data.get('content'):
new_text = data.get('content')
if new_text:
utils.log("WRITER", f" -> Rewriting Ch {target_chap['num']} to fix continuity.")
target_chap['content'] = new_text
changes_made = True
current_context = f"Ch {target_chap['num']} Summary: " + new_text[-2000:]
consecutive_no_changes = 0
try:
with open(os.path.join(folder, "manuscript.json"), 'w') as f: json.dump(manuscript, f, indent=2)
except: pass
except Exception as e:
utils.log("WRITER", f" -> Check failed: {e}")
return manuscript if changes_made else None

265
story/planner.py Normal file
View File

@@ -0,0 +1,265 @@
import json
import random
from core import utils
from ai import models as ai_models
from story.bible_tracker import filter_characters
def enrich(bp, folder, context=""):
utils.log("ENRICHER", "Fleshing out details from description...")
if 'book_metadata' not in bp: bp['book_metadata'] = {}
if 'characters' not in bp: bp['characters'] = []
if 'plot_beats' not in bp: bp['plot_beats'] = []
prompt = f"""
ROLE: Creative Director
TASK: Create a comprehensive Book Bible from the user description.
INPUT DATA:
- USER_DESCRIPTION: "{bp.get('manual_instruction', 'A generic story')}"
- CONTEXT (Sequel): {context}
STEPS:
1. Generate a catchy Title.
2. Define the Genre and Tone.
3. Determine the Time Period (e.g. "Modern", "1920s", "Sci-Fi Future").
4. Define Formatting Rules for text messages, thoughts, and chapter headers.
5. Create Protagonist and Antagonist/Love Interest.
- Logic: If sequel, reuse context. If new, create.
6. Outline 5-7 core Plot Beats.
7. Define a 'structure_prompt' describing the narrative arc (e.g. "Hero's Journey", "3-Act Structure", "Detective Procedural").
OUTPUT_FORMAT (JSON):
{{
"book_metadata": {{ "title": "Book Title", "genre": "Genre", "content_warnings": ["Violence", "Major Character Death"], "structure_prompt": "...", "style": {{ "tone": "Tone", "time_period": "Modern", "formatting_rules": ["Chapter Headers: Number + Title", "Text Messages: Italic", "Thoughts: Italic"] }} }},
"characters": [ {{ "name": "John Doe", "role": "Protagonist", "description": "Description", "key_events": ["Planned injury in Act 2"] }} ],
"plot_beats": [ "Beat 1", "Beat 2", "..." ]
}}
"""
try:
response = ai_models.model_logic.generate_content(prompt)
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
ai_data = json.loads(utils.clean_json(response.text))
if 'book_metadata' not in bp: bp['book_metadata'] = {}
if 'title' not in bp['book_metadata']:
bp['book_metadata']['title'] = ai_data.get('book_metadata', {}).get('title')
if 'structure_prompt' not in bp['book_metadata']:
bp['book_metadata']['structure_prompt'] = ai_data.get('book_metadata', {}).get('structure_prompt')
if 'content_warnings' not in bp['book_metadata']:
bp['book_metadata']['content_warnings'] = ai_data.get('book_metadata', {}).get('content_warnings', [])
if 'style' not in bp['book_metadata']: bp['book_metadata']['style'] = {}
source_style = ai_data.get('book_metadata', {}).get('style', {})
for k, v in source_style.items():
if k not in bp['book_metadata']['style']:
bp['book_metadata']['style'][k] = v
if 'characters' not in bp or not bp['characters']:
bp['characters'] = ai_data.get('characters', [])
if 'characters' in bp:
bp['characters'] = filter_characters(bp['characters'])
if 'plot_beats' not in bp or not bp['plot_beats']:
bp['plot_beats'] = ai_data.get('plot_beats', [])
return bp
except Exception as e:
utils.log("ENRICHER", f"Enrichment failed: {e}")
return bp
def plan_structure(bp, folder):
utils.log("ARCHITECT", "Creating structure...")
structure_type = bp.get('book_metadata', {}).get('structure_prompt')
if not structure_type:
label = bp.get('length_settings', {}).get('label', 'Novel')
structures = {
"Chapter Book": "Create a simple episodic structure with clear chapter hooks.",
"Young Adult": "Create a character-driven arc with high emotional stakes and a clear 'Coming of Age' theme.",
"Flash Fiction": "Create a single, impactful scene structure with a twist.",
"Short Story": "Create a concise narrative arc (Inciting Incident -> Rising Action -> Climax -> Resolution).",
"Novella": "Create a standard 3-Act Structure.",
"Novel": "Create a detailed 3-Act Structure with A and B plots.",
"Epic": "Create a complex, multi-arc structure (Hero's Journey) with extensive world-building events."
}
structure_type = structures.get(label, "Create a 3-Act Structure.")
beats_context = bp.get('plot_beats', [])
target_chapters = bp.get('length_settings', {}).get('chapters', 'flexible')
target_words = bp.get('length_settings', {}).get('words', 'flexible')
chars_summary = [{"name": c.get("name"), "role": c.get("role")} for c in bp.get('characters', [])]
prompt = f"""
ROLE: Story Architect
TASK: Create a detailed structural event outline for a {target_chapters}-chapter book.
BOOK:
- TITLE: {bp['book_metadata']['title']}
- GENRE: {bp.get('book_metadata', {}).get('genre', 'Fiction')}
- TARGET_CHAPTERS: {target_chapters}
- TARGET_WORDS: {target_words}
- STRUCTURE: {structure_type}
CHARACTERS: {json.dumps(chars_summary)}
USER_BEATS (must all be preserved and woven into the outline):
{json.dumps(beats_context)}
REQUIREMENTS:
- Produce enough events to fill approximately {target_chapters} chapters.
- Each event must serve a narrative purpose (setup, escalation, reversal, climax, resolution).
- Distribute events across a beginning, middle, and end — avoid front-loading.
- Character arcs must be visible through the events (growth, change, revelation).
OUTPUT_FORMAT (JSON): {{ "events": [{{ "description": "String", "purpose": "String" }}] }}
"""
try:
response = ai_models.model_logic.generate_content(prompt)
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
return json.loads(utils.clean_json(response.text))['events']
except:
return []
def expand(events, pass_num, target_chapters, bp, folder):
utils.log("ARCHITECT", f"Expansion pass {pass_num} | Current Beats: {len(events)} | Target Chaps: {target_chapters}")
event_ceiling = int(target_chapters * 1.5)
if len(events) >= event_ceiling:
task = (
f"The outline already has {len(events)} beats for a {target_chapters}-chapter book — do NOT add more events. "
f"Instead, enrich each existing beat's description with more specific detail: setting, characters involved, emotional stakes, and how it connects to what follows."
)
else:
task = (
f"Expand the outline toward {target_chapters} chapters. "
f"Current count: {len(events)} beats. "
f"Add intermediate events to fill pacing gaps, deepen subplots, and ensure character arcs are visible. "
f"Do not overshoot — aim for {target_chapters} to {event_ceiling} total events."
)
original_beats = bp.get('plot_beats', [])
prompt = f"""
ROLE: Story Architect
TASK: {task}
ORIGINAL_USER_BEATS (must all remain present):
{json.dumps(original_beats)}
CURRENT_EVENTS:
{json.dumps(events)}
RULES:
1. PRESERVE all original user beats — do not remove or alter them.
2. New events must serve a clear narrative purpose (tension, character, world, reversal).
3. Avoid repetitive events — each beat must be distinct.
4. Distribute additions evenly — do not front-load the outline.
OUTPUT_FORMAT (JSON): {{ "events": [{{"description": "String", "purpose": "String"}}] }}
"""
try:
response = ai_models.model_logic.generate_content(prompt)
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
new_events = json.loads(utils.clean_json(response.text))['events']
if len(new_events) > len(events):
utils.log("ARCHITECT", f" -> Added {len(new_events) - len(events)} new beats.")
elif len(str(new_events)) > len(str(events)) + 20:
utils.log("ARCHITECT", f" -> Fleshed out descriptions (Text grew by {len(str(new_events)) - len(str(events))} chars).")
else:
utils.log("ARCHITECT", " -> No significant changes.")
return new_events
except Exception as e:
utils.log("ARCHITECT", f" -> Pass skipped due to error: {e}")
return events
def create_chapter_plan(events, bp, folder):
utils.log("ARCHITECT", "Finalizing Chapters...")
target = bp['length_settings']['chapters']
words = bp['length_settings'].get('words', 'Flexible')
include_prologue = bp.get('length_settings', {}).get('include_prologue', False)
include_epilogue = bp.get('length_settings', {}).get('include_epilogue', False)
structure_instructions = ""
if include_prologue: structure_instructions += "- Include a 'Prologue' (chapter_number: 0) to set the scene.\n"
if include_epilogue: structure_instructions += "- Include an 'Epilogue' (chapter_number: 'Epilogue') to wrap up.\n"
meta = bp.get('book_metadata', {})
style = meta.get('style', {})
pov_chars = style.get('pov_characters', [])
pov_instruction = ""
if pov_chars:
pov_instruction = f"- Assign a 'pov_character' for each chapter from this list: {json.dumps(pov_chars)}."
prompt = f"""
ROLE: Pacing Specialist
TASK: Group the provided events into chapters for a {meta.get('genre', 'Fiction')} {bp['length_settings'].get('label', 'novel')}.
GUIDELINES:
- AIM for approximately {target} chapters, but the final count may vary ±15% if the story structure demands it.
- TARGET_WORDS for the whole book: {words}
- Assign pacing to each chapter: Very Fast / Fast / Standard / Slow / Very Slow
- estimated_words per chapter should reflect its pacing:
Very Fast ≈ 60% of average, Fast ≈ 80%, Standard ≈ 100%, Slow ≈ 125%, Very Slow ≈ 150%
- Do NOT force equal word counts. Natural variation makes the book feel alive.
{structure_instructions}
{pov_instruction}
INPUT_EVENTS: {json.dumps(events)}
OUTPUT_FORMAT (JSON): [{{"chapter_number": 1, "title": "String", "pov_character": "String", "pacing": "String", "estimated_words": 2000, "beats": ["String"]}}]
"""
try:
response = ai_models.model_logic.generate_content(prompt)
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
plan = json.loads(utils.clean_json(response.text))
target_str = str(words).lower().replace(',', '').replace('k', '000').replace('+', '').replace(' ', '')
target_val = 0
if '-' in target_str:
try:
parts = target_str.split('-')
target_val = int((int(parts[0]) + int(parts[1])) / 2)
except: pass
else:
try: target_val = int(target_str)
except: pass
if target_val > 0:
variance = random.uniform(0.92, 1.08)
target_val = int(target_val * variance)
utils.log("ARCHITECT", f"Word target after variance ({variance:.2f}x): {target_val} words.")
current_sum = sum(int(c.get('estimated_words', 0)) for c in plan)
if current_sum > 0:
base_factor = target_val / current_sum
pacing_weight = {
'very fast': 0.60, 'fast': 0.80, 'standard': 1.00,
'slow': 1.25, 'very slow': 1.50
}
for c in plan:
pw = pacing_weight.get(c.get('pacing', 'standard').lower(), 1.0)
c['estimated_words'] = max(300, int(c.get('estimated_words', 0) * base_factor * pw))
adjusted_sum = sum(c['estimated_words'] for c in plan)
if adjusted_sum > 0:
norm = target_val / adjusted_sum
for c in plan:
c['estimated_words'] = max(300, int(c['estimated_words'] * norm))
utils.log("ARCHITECT", f"Chapter lengths scaled by pacing. Total ≈ {sum(c['estimated_words'] for c in plan)} words across {len(plan)} chapters.")
return plan
except Exception as e:
utils.log("ARCHITECT", f"Failed to create chapter plan: {e}")
return []

180
story/style_persona.py Normal file
View File

@@ -0,0 +1,180 @@
import json
import os
import time
from core import config, utils
from ai import models as ai_models
def get_style_guidelines():
defaults = {
"ai_isms": [
'testament to', 'tapestry', 'shiver down spine', 'unspoken agreement',
'palpable tension', 'a sense of', 'suddenly', 'in that moment',
'symphony of', 'dance of', 'azure', 'cerulean'
],
"filter_words": [
'felt', 'saw', 'heard', 'realized', 'decided', 'noticed', 'knew', 'thought'
]
}
path = os.path.join(config.DATA_DIR, "style_guidelines.json")
if os.path.exists(path):
try:
user_data = utils.load_json(path)
if user_data:
if 'ai_isms' in user_data: defaults['ai_isms'] = user_data['ai_isms']
if 'filter_words' in user_data: defaults['filter_words'] = user_data['filter_words']
except: pass
else:
try:
with open(path, 'w') as f: json.dump(defaults, f, indent=2)
except: pass
return defaults
def refresh_style_guidelines(model, folder=None):
utils.log("SYSTEM", "Refreshing Style Guidelines via AI...")
current = get_style_guidelines()
prompt = f"""
ROLE: Literary Editor
TASK: Update 'Banned Words' lists for AI writing.
INPUT_DATA:
- CURRENT_AI_ISMS: {json.dumps(current.get('ai_isms', []))}
- CURRENT_FILTER_WORDS: {json.dumps(current.get('filter_words', []))}
INSTRUCTIONS:
1. Review lists. Remove false positives.
2. Add new common AI tropes (e.g. 'neon-lit', 'bustling', 'a sense of', 'mined', 'delved').
3. Ensure robustness.
OUTPUT_FORMAT (JSON): {{ "ai_isms": [strings], "filter_words": [strings] }}
"""
try:
response = model.generate_content(prompt)
model_name = getattr(model, 'name', ai_models.logic_model_name)
if folder: utils.log_usage(folder, model_name, response.usage_metadata)
new_data = json.loads(utils.clean_json(response.text))
if 'ai_isms' in new_data and 'filter_words' in new_data:
path = os.path.join(config.DATA_DIR, "style_guidelines.json")
with open(path, 'w') as f: json.dump(new_data, f, indent=2)
utils.log("SYSTEM", "Style Guidelines updated.")
return new_data
except Exception as e:
utils.log("SYSTEM", f"Failed to refresh guidelines: {e}")
return current
def create_initial_persona(bp, folder):
utils.log("SYSTEM", "Generating initial Author Persona based on genre/tone...")
meta = bp.get('book_metadata', {})
style = meta.get('style', {})
prompt = f"""
ROLE: Creative Director
TASK: Create a fictional 'Author Persona'.
METADATA:
- TITLE: {meta.get('title')}
- GENRE: {meta.get('genre')}
- TONE: {style.get('tone')}
- AUDIENCE: {meta.get('target_audience')}
OUTPUT_FORMAT (JSON): {{ "name": "Pen Name", "bio": "Description of writing style (voice, sentence structure, vocabulary)...", "age": "...", "gender": "..." }}
"""
try:
response = ai_models.model_logic.generate_content(prompt)
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
return json.loads(utils.clean_json(response.text))
except Exception as e:
utils.log("SYSTEM", f"Persona generation failed: {e}")
return {"name": "AI Author", "bio": "Standard, balanced writing style."}
def refine_persona(bp, text, folder):
utils.log("SYSTEM", "Refining Author Persona based on recent chapters...")
ad = bp.get('book_metadata', {}).get('author_details', {})
current_bio = ad.get('bio', 'Standard style.')
prompt = f"""
ROLE: Literary Stylist
TASK: Refine Author Bio based on text sample.
INPUT_DATA:
- TEXT_SAMPLE: {text[:3000]}
- CURRENT_BIO: {current_bio}
GOAL: Ensure future chapters sound exactly like the sample. Highlight quirks, patterns, vocabulary.
OUTPUT_FORMAT (JSON): {{ "bio": "Updated bio..." }}
"""
try:
response = ai_models.model_logic.generate_content(prompt)
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
new_bio = json.loads(utils.clean_json(response.text)).get('bio')
if new_bio:
ad['bio'] = new_bio
utils.log("SYSTEM", " -> Persona bio updated.")
return ad
except: pass
return ad
def update_persona_sample(bp, folder):
utils.log("SYSTEM", "Extracting author persona from manuscript...")
ms_path = os.path.join(folder, "manuscript.json")
if not os.path.exists(ms_path): return
ms = utils.load_json(ms_path)
if not ms: return
full_text = "\n".join([c.get('content', '') for c in ms])
if len(full_text) < 500: return
if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR)
meta = bp.get('book_metadata', {})
safe_title = utils.sanitize_filename(meta.get('title', 'book'))[:20]
timestamp = int(time.time())
filename = f"sample_{safe_title}_{timestamp}.txt"
filepath = os.path.join(config.PERSONAS_DIR, filename)
sample_text = full_text[:3000]
with open(filepath, 'w', encoding='utf-8') as f: f.write(sample_text)
author_name = meta.get('author', 'Unknown Author')
personas = {}
if os.path.exists(config.PERSONAS_FILE):
try:
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
except: pass
if author_name not in personas:
utils.log("SYSTEM", f"Generating new persona profile for '{author_name}'...")
prompt = f"""
ROLE: Literary Analyst
TASK: Analyze writing style (Tone, Voice, Vocabulary).
TEXT: {sample_text[:1000]}
OUTPUT: 1-sentence author bio.
"""
try:
response = ai_models.model_logic.generate_content(prompt)
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
bio = response.text.strip()
except: bio = "Style analysis unavailable."
personas[author_name] = {
"name": author_name,
"bio": bio,
"sample_files": [filename],
"sample_text": sample_text[:500]
}
else:
utils.log("SYSTEM", f"Updating persona '{author_name}' with new sample.")
if 'sample_files' not in personas[author_name]: personas[author_name]['sample_files'] = []
if filename not in personas[author_name]['sample_files']:
personas[author_name]['sample_files'].append(filename)
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2)

278
story/writer.py Normal file
View File

@@ -0,0 +1,278 @@
import json
import os
from core import config, utils
from ai import models as ai_models
from story.style_persona import get_style_guidelines
from story.editor import evaluate_chapter_quality
def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None, next_chapter_hint=""):
pacing = chap.get('pacing', 'Standard')
est_words = chap.get('estimated_words', 'Flexible')
utils.log("WRITER", f"Drafting Ch {chap['chapter_number']} ({pacing} | ~{est_words} words): {chap['title']}")
ls = bp['length_settings']
meta = bp.get('book_metadata', {})
style = meta.get('style', {})
genre = meta.get('genre', 'Fiction')
pov_char = chap.get('pov_character', '')
ad = meta.get('author_details', {})
if not ad and 'author_bio' in meta:
persona_info = meta['author_bio']
else:
persona_info = f"Name: {ad.get('name', meta.get('author', 'Unknown'))}\n"
if ad.get('age'): persona_info += f"Age: {ad['age']}\n"
if ad.get('gender'): persona_info += f"Gender: {ad['gender']}\n"
if ad.get('race'): persona_info += f"Race: {ad['race']}\n"
if ad.get('nationality'): persona_info += f"Nationality: {ad['nationality']}\n"
if ad.get('language'): persona_info += f"Language: {ad['language']}\n"
if ad.get('bio'): persona_info += f"Style/Bio: {ad['bio']}\n"
samples = []
if ad.get('sample_text'):
samples.append(f"--- SAMPLE PARAGRAPH ---\n{ad['sample_text']}")
if ad.get('sample_files'):
for fname in ad['sample_files']:
fpath = os.path.join(config.PERSONAS_DIR, fname)
if os.path.exists(fpath):
try:
with open(fpath, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read(3000)
samples.append(f"--- SAMPLE FROM {fname} ---\n{content}...")
except: pass
if samples:
persona_info += "\nWRITING STYLE SAMPLES:\n" + "\n".join(samples)
char_visuals = ""
if tracking and 'characters' in tracking:
char_visuals = "\nCHARACTER TRACKING (Visuals & Preferences):\n"
for name, data in tracking['characters'].items():
desc = ", ".join(data.get('descriptors', []))
likes = ", ".join(data.get('likes_dislikes', []))
speech = data.get('speech_style', 'Unknown')
worn = data.get('last_worn', 'Unknown')
char_visuals += f"- {name}: {desc}\n * Speech: {speech}\n * Likes/Dislikes: {likes}\n"
major = data.get('major_events', [])
if major: char_visuals += f" * Major Events: {'; '.join(major)}\n"
if worn and worn != 'Unknown':
char_visuals += f" * Last Worn: {worn} (NOTE: Only relevant if scene is continuous from previous chapter)\n"
style_block = "\n".join([f"- {k.replace('_', ' ').title()}: {v}" for k, v in style.items() if isinstance(v, (str, int, float))])
if 'tropes' in style and isinstance(style['tropes'], list):
style_block += f"\n- Tropes: {', '.join(style['tropes'])}"
if 'formatting_rules' in style and isinstance(style['formatting_rules'], list):
style_block += "\n- Formatting Rules:\n * " + "\n * ".join(style['formatting_rules'])
prev_context_block = ""
if prev_content:
trunc_content = prev_content[-3000:] if len(prev_content) > 3000 else prev_content
prev_context_block = f"\nPREVIOUS CHAPTER TEXT (For Tone & Continuity):\n{trunc_content}\n"
chars_for_writer = [
{"name": c.get("name"), "role": c.get("role"), "description": c.get("description", "")}
for c in bp.get('characters', [])
]
total_chapters = ls.get('chapters', '?')
prompt = f"""
ROLE: Fiction Writer
TASK: Write Chapter {chap['chapter_number']}: {chap['title']}
METADATA:
- GENRE: {genre}
- FORMAT: {ls.get('label', 'Story')}
- POSITION: Chapter {chap['chapter_number']} of {total_chapters} — calibrate narrative tension accordingly (early = setup/intrigue, middle = escalation, final third = payoff/climax)
- PACING: {pacing} — see PACING_GUIDE below
- TARGET_WORDS: ~{est_words} (write to this length; do not summarise to save space)
- POV: {pov_char if pov_char else 'Protagonist'}
PACING_GUIDE:
- 'Very Fast': Pure action/dialogue. Minimal description. Short punchy paragraphs.
- 'Fast': Keep momentum. No lingering. Cut to the next beat quickly.
- 'Standard': Balanced dialogue and description. Standard paragraph lengths.
- 'Slow': Detailed, atmospheric. Linger on emotion and environment.
- 'Very Slow': Deep introspection. Heavy sensory immersion. Slow burn tension.
STYLE_GUIDE:
{style_block}
AUTHOR_VOICE:
{persona_info}
INSTRUCTIONS:
- Start with the Chapter Header formatted as Markdown H1 (e.g. '# Chapter X: Title'). Follow the 'Formatting Rules' for the header style.
- SENSORY ANCHORING: Start scenes by establishing Who, Where, and When immediately.
- DEEP POV: Immerse the reader in the POV character's immediate experience. Filter descriptions through their specific worldview and emotional state.
- SHOW, DON'T TELL: Focus on immediate action and internal reaction. Don't summarize feelings; show the physical manifestation of them.
- CAUSALITY: Ensure events follow a "Because of X, Y happened" logic, not just "And then X, and then Y".
- STAGING: When characters enter, describe their entrance. Don't let them just "appear" in dialogue.
- SENSORY DETAILS: Use specific sensory details sparingly to ground the scene. Avoid stacking adjectives (e.g. "crisp white blouses, sharp legal briefs").
- ACTIVE VOICE: Use active voice. Subject -> Verb -> Object. Avoid "was/were" constructions.
- STRONG VERBS: Delete adverbs. Use specific verbs (e.g. "trudged" instead of "walked slowly").
- NO INFO-DUMPS: Weave backstory into dialogue or action. Do not stop the story to explain history.
- AVOID CLICHÉS: Avoid common AI tropes (e.g., 'shiver down spine', 'palpable tension', 'unspoken agreement', 'testament to', 'tapestry of', 'azure', 'cerulean').
- MAINTAIN CONTINUITY: Pay close attention to the PREVIOUS CONTEXT. Characters must NOT know things that haven't happened yet or haven't been revealed to them.
- CHARACTER INTERACTIONS: If characters are meeting for the first time in the summary, treat them as strangers.
- SENTENCE VARIETY: Avoid repetitive sentence structures (e.g. starting multiple sentences with "He" or "She"). Vary sentence length to create rhythm.
- GENRE CONSISTENCY: Ensure all introductions of characters, places, items, or actions are strictly appropriate for the {genre} genre. Avoid anachronisms or tonal clashes.
- DIALOGUE VOICE: Every character speaks with their own distinct voice (see CHARACTER TRACKING for speech styles). No two characters may sound the same. Vary sentence length, vocabulary, and register per character.
- CHAPTER HOOK: End this chapter with unresolved tension — a decision pending, a threat imminent, or a question unanswered.{f" Seed subtle anticipation for the next scene: '{next_chapter_hint}'." if next_chapter_hint else " Do not neatly resolve all threads."}
QUALITY_CRITERIA:
1. ENGAGEMENT & TENSION: Grip the reader. Ensure conflict/tension in every scene.
2. SCENE EXECUTION: Flesh out the middle. Avoid summarizing key moments.
3. VOICE & TONE: Distinct narrative voice matching the genre.
4. SENSORY IMMERSION: Engage all five senses.
5. SHOW, DON'T TELL: Show emotions through physical reactions and subtext.
6. CHARACTER AGENCY: Characters must drive the plot through active choices.
7. PACING: Avoid rushing. Ensure the ending lands with impact.
8. GENRE APPROPRIATENESS: Introductions of characters, places, items, or actions must be consistent with {genre} conventions.
9. DIALOGUE AUTHENTICITY: Characters must sound distinct. Use subtext. Avoid "on-the-nose" dialogue.
10. PLOT RELEVANCE: Every scene must advance the plot or character arcs. No filler.
11. STAGING & FLOW: Characters must enter and exit physically. Paragraphs must transition logically.
12. PROSE DYNAMICS: Vary sentence length. Use strong verbs. Avoid passive voice.
13. CLARITY: Ensure sentences are clear and readable. Avoid convoluted phrasing.
CONTEXT:
- STORY_SO_FAR: {prev_sum}
{prev_context_block}
- CHARACTERS: {json.dumps(chars_for_writer)}
{char_visuals}
- SCENE_BEATS: {json.dumps(chap['beats'])}
OUTPUT: Markdown text.
"""
current_text = ""
try:
resp_draft = ai_models.model_writer.generate_content(prompt)
utils.log_usage(folder, ai_models.model_writer.name, resp_draft.usage_metadata)
current_text = resp_draft.text
draft_words = len(current_text.split()) if current_text else 0
utils.log("WRITER", f" -> Draft: {draft_words:,} words (target: ~{est_words})")
except Exception as e:
utils.log("WRITER", f"⚠️ Failed Ch {chap['chapter_number']}: {e}")
return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}"
max_attempts = 5
SCORE_AUTO_ACCEPT = 8
SCORE_PASSING = 7
SCORE_REWRITE_THRESHOLD = 6
best_score = 0
best_text = current_text
past_critiques = []
for attempt in range(1, max_attempts + 1):
utils.log("WRITER", f" -> Evaluating Ch {chap['chapter_number']} (Attempt {attempt}/{max_attempts})...")
score, critique = evaluate_chapter_quality(current_text, chap['title'], meta.get('genre', 'Fiction'), ai_models.model_writer, folder)
past_critiques.append(f"Attempt {attempt}: {critique}")
if "Evaluation error" in critique:
utils.log("WRITER", f" ⚠️ {critique}. Keeping current draft.")
if best_score == 0: best_text = current_text
break
utils.log("WRITER", f" Score: {score}/10. Critique: {critique}")
if score >= SCORE_AUTO_ACCEPT:
utils.log("WRITER", " 🌟 Auto-Accept threshold met.")
return current_text
if score > best_score:
best_score = score
best_text = current_text
if attempt == max_attempts:
if best_score >= SCORE_PASSING:
utils.log("WRITER", f" ✅ Max attempts reached. Accepting best score ({best_score}).")
return best_text
else:
utils.log("WRITER", f" ⚠️ Quality low ({best_score}/{SCORE_PASSING}) but max attempts reached. Proceeding.")
return best_text
if score < SCORE_REWRITE_THRESHOLD:
utils.log("WRITER", f" -> Score {score} < {SCORE_REWRITE_THRESHOLD}. Triggering FULL REWRITE (Fresh Draft)...")
full_rewrite_prompt = prompt + f"""
[SYSTEM ALERT: QUALITY CHECK FAILED]
The previous draft was rejected.
CRITIQUE: {critique}
NEW TASK: Discard the previous attempt. Write a FRESH version of the chapter that addresses the critique above.
"""
try:
resp_rewrite = ai_models.model_logic.generate_content(full_rewrite_prompt)
utils.log_usage(folder, ai_models.model_logic.name, resp_rewrite.usage_metadata)
current_text = resp_rewrite.text
continue
except Exception as e:
utils.log("WRITER", f"Full rewrite failed: {e}. Falling back to refinement.")
utils.log("WRITER", f" -> Refining Ch {chap['chapter_number']} based on feedback...")
guidelines = get_style_guidelines()
fw_list = '", "'.join(guidelines['filter_words'])
history_str = "\n".join(past_critiques[-3:-1]) if len(past_critiques) > 1 else "None"
refine_prompt = f"""
ROLE: Automated Editor
TASK: Rewrite the draft chapter to address the critique. Preserve the narrative content and approximate word count.
CURRENT_CRITIQUE:
{critique}
PREVIOUS_ATTEMPTS (context only):
{history_str}
HARD_CONSTRAINTS:
- TARGET_WORDS: ~{est_words} words (aim for this; ±20% is acceptable if the scene genuinely demands it — but do not condense beats to save space)
- BEATS MUST BE COVERED: {json.dumps(chap.get('beats', []))}
- SUMMARY CONTEXT: {prev_sum[:1500]}
AUTHOR_VOICE:
{persona_info}
STYLE:
{style_block}
{char_visuals}
PROSE_RULES (fix each one found in the draft):
1. FILTER_REMOVAL: Remove filter words [{fw_list}] — rewrite to show the sensation directly.
2. VARIETY: No two consecutive sentences starting with the same word or pronoun.
3. SUBTEXT: Dialogue must imply meaning — not state it outright.
4. TONE: Match {meta.get('genre', 'Fiction')} conventions throughout.
5. ENVIRONMENT: Characters interact with their physical space.
6. NO_SUMMARY_MODE: Dramatise key moments — do not skip or summarise them.
7. ACTIVE_VOICE: Replace 'was/were + verb-ing' constructions with active alternatives.
8. SHOWING: Render emotion through physical reactions, not labels.
9. STAGING: Characters must enter and exit physically — no teleporting.
10. CLARITY: Prefer simple sentence structures over convoluted ones.
DRAFT_TO_REWRITE:
{current_text}
PREVIOUS_CHAPTER_ENDING (maintain continuity):
{prev_context_block}
OUTPUT: Complete polished chapter in Markdown. Include the chapter header. Same approximate length as the draft.
"""
try:
resp_refine = ai_models.model_writer.generate_content(refine_prompt)
utils.log_usage(folder, ai_models.model_writer.name, resp_refine.usage_metadata)
current_text = resp_refine.text
except Exception as e:
utils.log("WRITER", f"Refinement failed: {e}")
return best_text
return best_text

View File

@@ -7,8 +7,8 @@
<p class="text-muted">System management and user administration.</p>
</div>
<div class="col-md-4 text-end">
<a href="{{ url_for('admin_spend_report') }}" class="btn btn-outline-primary me-2"><i class="fas fa-chart-line me-2"></i>Spend Report</a>
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary">Back to Dashboard</a>
<a href="{{ url_for('admin.admin_spend_report') }}" class="btn btn-outline-primary me-2"><i class="fas fa-chart-line me-2"></i>Spend Report</a>
<a href="{{ url_for('project.index') }}" class="btn btn-outline-secondary">Back to Dashboard</a>
</div>
</div>
@@ -41,7 +41,7 @@
<td>
{% if u.id != current_user.id %}
<form action="/admin/user/{{ u.id }}/delete" method="POST" onsubmit="return confirm('Delete user {{ u.username }} and ALL their projects? This cannot be undone.');">
<a href="{{ url_for('impersonate_user', user_id=u.id) }}" class="btn btn-sm btn-outline-dark me-1" title="Impersonate User">
<a href="{{ url_for('admin.impersonate_user', user_id=u.id) }}" class="btn btn-sm btn-outline-dark me-1" title="Impersonate User">
<i class="fas fa-user-secret"></i>
</a>
<button class="btn btn-sm btn-outline-danger" title="Delete User"><i class="fas fa-trash"></i></button>
@@ -67,7 +67,7 @@
</div>
<div class="card-body">
<p class="text-muted small">Manage global AI writing rules and banned words.</p>
<a href="{{ url_for('admin_style_guidelines') }}" class="btn btn-outline-primary w-100"><i class="fas fa-spell-check me-2"></i>Edit Style Guidelines</a>
<a href="{{ url_for('admin.admin_style_guidelines') }}" class="btn btn-outline-primary w-100"><i class="fas fa-spell-check me-2"></i>Edit Style Guidelines</a>
</div>
</div>

View File

@@ -7,7 +7,7 @@
<p class="text-muted">Aggregate cost analysis per user.</p>
</div>
<div class="col-md-4 text-end">
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-secondary">Back to Admin</a>
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-outline-secondary">Back to Admin</a>
</div>
</div>

View File

@@ -5,7 +5,7 @@
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="fas fa-spell-check me-2 text-primary"></i>Style Guidelines</h2>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-secondary">Back to Admin</a>
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-outline-secondary">Back to Admin</a>
</div>
<div class="card shadow-sm">
@@ -36,7 +36,7 @@
<button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-save me-2"></i>Save Guidelines
</button>
<button type="submit" formaction="{{ url_for('optimize_models') }}" class="btn btn-outline-info w-100 mt-2">
<button type="submit" formaction="{{ url_for('admin.optimize_models') }}" class="btn btn-outline-info w-100 mt-2">
<i class="fas fa-magic me-2"></i>Auto-Refresh with AI
</button>
</div>

View File

@@ -28,7 +28,7 @@
{% if session.get('original_admin_id') %}
<div class="bg-danger text-white text-center py-2 shadow-sm" style="position: sticky; top: 0; z-index: 1050;">
<strong><i class="fas fa-user-secret me-2"></i>Viewing site as {{ current_user.username }}</strong>
<a href="{{ url_for('stop_impersonate') }}" class="btn btn-sm btn-light ms-3 text-danger fw-bold">Stop Impersonating</a>
<a href="{{ url_for('admin.stop_impersonate') }}" class="btn btn-sm btn-light ms-3 text-danger fw-bold">Stop Impersonating</a>
</div>
{% endif %}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">

View File

@@ -5,7 +5,7 @@
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="fas fa-search me-2"></i>Consistency Report</h2>
<a href="{{ url_for('view_run', id=run.id) }}" class="btn btn-outline-secondary">Back to Run</a>
<a href="{{ url_for('run.view_run', id=run.id) }}" class="btn btn-outline-secondary">Back to Run</a>
</div>
<div class="card shadow-sm mb-4">

View File

@@ -8,7 +8,7 @@
<h4 class="mb-0">{% if name %}Edit Persona: {{ name }}{% else %}New Persona{% endif %}</h4>
</div>
<div class="card-body">
<form action="{{ url_for('save_persona') }}" method="POST">
<form action="{{ url_for('persona.save_persona') }}" method="POST">
<input type="hidden" name="old_name" value="{{ name }}">
<div class="mb-3">
@@ -72,7 +72,7 @@
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('list_personas') }}" class="btn btn-outline-secondary">Cancel</a>
<a href="{{ url_for('persona.list_personas') }}" class="btn btn-outline-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Save Persona</button>
</div>
</form>

View File

@@ -3,7 +3,7 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="fas fa-users me-2"></i>Author Personas</h2>
<a href="{{ url_for('new_persona') }}" class="btn btn-primary"><i class="fas fa-plus me-2"></i>Create New Persona</a>
<a href="{{ url_for('persona.new_persona') }}" class="btn btn-primary"><i class="fas fa-plus me-2"></i>Create New Persona</a>
</div>
<div class="row">
@@ -16,8 +16,8 @@
<p class="card-text small">{{ p.bio[:150] }}...</p>
</div>
<div class="card-footer bg-white border-top-0 d-flex justify-content-between">
<a href="{{ url_for('edit_persona', name=name) }}" class="btn btn-sm btn-outline-primary">Edit</a>
<form action="{{ url_for('delete_persona', name=name) }}" method="POST" onsubmit="return confirm('Delete this persona?');">
<a href="{{ url_for('persona.edit_persona', name=name) }}" class="btn btn-sm btn-outline-primary">Edit</a>
<form action="{{ url_for('persona.delete_persona', name=name) }}" method="POST" onsubmit="return confirm('Delete this persona?');">
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</div>

View File

@@ -164,7 +164,7 @@
</td>
<td>${{ "%.4f"|format(r.cost) }}</td>
<td>
<a href="{{ url_for('view_run', id=r.id) }}" class="btn btn-sm btn-outline-primary">
<a href="{{ url_for('run.view_run', id=r.id) }}" class="btn btn-sm btn-outline-primary">
{{ 'View Active' if active_run and r.id == active_run.id and active_run.status in ['running', 'queued'] else 'View' }}
</a>
{% if r.status in ['failed', 'cancelled', 'interrupted'] %}

View File

@@ -7,12 +7,12 @@
<small class="text-muted">Run #{{ run.id }}</small>
</div>
<div>
<form action="{{ url_for('sync_book_metadata', run_id=run.id, book_folder=book_folder) }}" method="POST" class="d-inline me-2" onsubmit="return confirm('This will re-scan your manuscript to update the character list and author persona. Continue?');">
<form action="{{ url_for('run.sync_book_metadata', run_id=run.id, book_folder=book_folder) }}" method="POST" class="d-inline me-2" onsubmit="return confirm('This will re-scan your manuscript to update the character list and author persona. Continue?');">
<button type="submit" class="btn btn-outline-info" data-bs-toggle="tooltip" title="Scans your manual edits to update the character database and author writing style. Use this after making significant edits.">
<i class="fas fa-sync me-2"></i>Sync Metadata
</button>
</form>
<a href="{{ url_for('view_run', id=run.id) }}" class="btn btn-outline-secondary">Back to Run</a>
<a href="{{ url_for('run.view_run', id=run.id) }}" class="btn btn-outline-secondary">Back to Run</a>
</div>
</div>

View File

@@ -4,7 +4,7 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2><i class="fas fa-book me-2"></i>Run #{{ run.id }}</h2>
<p class="text-muted mb-0">Project: <a href="{{ url_for('view_project', id=run.project_id) }}">{{ run.project.name }}</a></p>
<p class="text-muted mb-0">Project: <a href="{{ url_for('project.view_project', id=run.project_id) }}">{{ run.project.name }}</a></p>
</div>
<div>
<button class="btn btn-outline-primary me-2" type="button" data-bs-toggle="collapse" data-bs-target="#bibleCollapse" aria-expanded="false" aria-controls="bibleCollapse">
@@ -13,7 +13,7 @@
<button class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#modifyRunModal" data-bs-toggle="tooltip" title="Create a new run based on this one, but with different instructions (e.g. 'Make it darker').">
<i class="fas fa-pen-fancy me-2"></i>Modify & Re-run
</button>
<a href="{{ url_for('view_project', id=run.project_id) }}" class="btn btn-outline-secondary">Back to Project</a>
<a href="{{ url_for('project.view_project', id=run.project_id) }}" class="btn btn-outline-secondary">Back to Project</a>
</div>
</div>
@@ -124,7 +124,7 @@
<div class="col-md-4 mb-3">
<div class="text-center">
{% if book.cover %}
<img src="{{ url_for('download_artifact', run_id=run.id, file=book.cover) }}" class="img-fluid rounded shadow-sm mb-3" alt="Book Cover" style="max-height: 400px;">
<img src="{{ url_for('run.download_artifact', run_id=run.id, file=book.cover) }}" class="img-fluid rounded shadow-sm mb-3" alt="Book Cover" style="max-height: 400px;">
{% else %}
<div class="alert alert-secondary py-5">
<i class="fas fa-image fa-3x mb-3"></i><br>No cover.
@@ -132,7 +132,7 @@
{% endif %}
{% if loop.first %}
<form action="{{ url_for('regenerate_artifacts', run_id=run.id) }}" method="POST" class="mt-2">
<form action="{{ url_for('run.regenerate_artifacts', run_id=run.id) }}" method="POST" class="mt-2">
<textarea name="feedback" class="form-control mb-2 form-control-sm" rows="1" placeholder="Cover Feedback..."></textarea>
<button type="submit" class="btn btn-sm btn-outline-primary w-100">
<i class="fas fa-sync me-2"></i>Regenerate All
@@ -156,7 +156,7 @@
<h6 class="fw-bold">Artifacts</h6>
<div class="d-flex flex-wrap gap-2">
{% for art in book.artifacts %}
<a href="{{ url_for('download_artifact', run_id=run.id, file=art.path) }}" class="btn btn-sm btn-outline-success">
<a href="{{ url_for('run.download_artifact', run_id=run.id, file=art.path) }}" class="btn btn-sm btn-outline-success">
<i class="fas fa-download me-1"></i> {{ art.name }}
</a>
{% else %}
@@ -164,10 +164,10 @@
{% endfor %}
<div class="mt-3">
<a href="{{ url_for('read_book', run_id=run.id, book_folder=book.folder) }}" class="btn btn-primary">
<a href="{{ url_for('run.read_book', run_id=run.id, book_folder=book.folder) }}" class="btn btn-primary">
<i class="fas fa-book-reader me-2"></i>Read & Edit
</a>
<a href="{{ url_for('check_consistency', run_id=run.id, book_folder=book.folder) }}" class="btn btn-outline-warning ms-2">
<a href="{{ url_for('run.check_consistency', run_id=run.id, book_folder=book.folder) }}" class="btn btn-outline-warning ms-2">
<i class="fas fa-search me-2"></i>Check Consistency
</a>
<button class="btn btn-warning ms-2" data-bs-toggle="modal" data-bs-target="#reviseBookModal{{ loop.index }}" title="Regenerate this book with changes, keeping others.">
@@ -183,7 +183,7 @@
<!-- Revise Book Modal -->
<div class="modal fade" id="reviseBookModal{{ loop.index }}" tabindex="-1">
<div class="modal-dialog">
<form class="modal-content" action="{{ url_for('revise_book', run_id=run.id, book_folder=book.folder) }}" method="POST">
<form class="modal-content" action="{{ url_for('project.revise_book', run_id=run.id, book_folder=book.folder) }}" method="POST">
<div class="modal-header">
<h5 class="modal-title">Revise Book</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>

View File

@@ -7,8 +7,8 @@
<p class="text-muted">AI Model Health, Selection Reasoning, and Availability.</p>
</div>
<div class="col-md-4 text-end">
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary me-2">Back to Dashboard</a>
<form action="{{ url_for('optimize_models') }}" method="POST" class="d-inline" onsubmit="return confirm('This will re-analyze all available models. Continue?');">
<a href="{{ url_for('project.index') }}" class="btn btn-outline-secondary me-2">Back to Dashboard</a>
<form action="{{ url_for('admin.optimize_models') }}" method="POST" class="d-inline" onsubmit="return confirm('This will re-analyze all available models. Continue?');">
<button type="submit" class="btn btn-primary">
<i class="fas fa-sync me-2"></i>Refresh & Optimize
</button>

0
web/__init__.py Normal file
View File

106
web/app.py Normal file
View File

@@ -0,0 +1,106 @@
import os
from datetime import datetime
from sqlalchemy import text
from flask import Flask
from flask_login import LoginManager
from werkzeug.security import generate_password_hash
from web.db import db, User, Run
from web.tasks import huey
from core import config
# Calculate paths relative to this file (web/app.py -> project root is two levels up)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEMPLATE_DIR = os.path.join(BASE_DIR, 'templates')
app = Flask(__name__, template_folder=TEMPLATE_DIR)
app.url_map.strict_slashes = False
app.config['SECRET_KEY'] = config.FLASK_SECRET
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(config.DATA_DIR, "bookapp.db")}'
db.init_app(app)
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
login_manager.init_app(app)
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))
@app.context_processor
def inject_globals():
return dict(app_version=config.VERSION)
# Register Blueprints
from web.routes.auth import auth_bp
from web.routes.project import project_bp
from web.routes.run import run_bp
from web.routes.persona import persona_bp
from web.routes.admin import admin_bp
app.register_blueprint(auth_bp)
app.register_blueprint(project_bp)
app.register_blueprint(run_bp)
app.register_blueprint(persona_bp)
app.register_blueprint(admin_bp)
# --- SETUP ---
with app.app_context():
db.create_all()
# Auto-create Admin from Environment Variables (Docker/Portainer Setup)
if config.ADMIN_USER and config.ADMIN_PASSWORD:
admin = User.query.filter_by(username=config.ADMIN_USER).first()
if not admin:
print(f"🔐 System: Creating Admin User '{config.ADMIN_USER}' from environment variables.")
admin = User(username=config.ADMIN_USER, password=generate_password_hash(config.ADMIN_PASSWORD, method='pbkdf2:sha256'), is_admin=True)
db.session.add(admin)
db.session.commit()
else:
print(f"🔐 System: Syncing Admin User '{config.ADMIN_USER}' settings from environment.")
if not admin.is_admin: admin.is_admin = True
admin.password = generate_password_hash(config.ADMIN_PASSWORD, method='pbkdf2:sha256')
db.session.add(admin)
db.session.commit()
elif not User.query.filter_by(is_admin=True).first():
print(" System: No Admin credentials found in environment variables. Admin account not created.")
# Migration: Add 'progress' column if missing
try:
with db.engine.connect() as conn:
conn.execute(text("ALTER TABLE run ADD COLUMN progress INTEGER DEFAULT 0"))
conn.commit()
print("✅ System: Added 'progress' column to Run table.")
except: pass
# Reset stuck runs on startup
try:
stuck_runs = Run.query.filter_by(status='running').all()
if stuck_runs:
print(f"⚠️ System: Found {len(stuck_runs)} stuck runs. Resetting to 'failed'.")
for r in stuck_runs:
r.status = 'failed'
r.end_time = datetime.utcnow()
db.session.commit()
except Exception as e:
print(f"⚠️ System: Failed to clean up stuck runs: {e}")
if __name__ == "__main__":
import threading
from huey.contrib.mini import MiniHuey
# Start Huey consumer in background thread
def run_huey():
from huey.consumer import Consumer
consumer = Consumer(huey, workers=1, worker_type='thread', loglevel=20)
consumer.run()
t = threading.Thread(target=run_huey, daemon=True)
t.start()
app.run(host='0.0.0.0', port=7070, debug=False)

View File

@@ -4,14 +4,16 @@ from datetime import datetime
db = SQLAlchemy()
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(150), unique=True, nullable=False)
password = db.Column(db.String(150), nullable=False)
api_key = db.Column(db.String(200), nullable=True) # Optional: User-specific Gemini Key
api_key = db.Column(db.String(200), nullable=True)
total_spend = db.Column(db.Float, default=0.0)
is_admin = db.Column(db.Boolean, default=False)
class Project(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
@@ -19,20 +21,19 @@ class Project(db.Model):
folder_path = db.Column(db.String(300), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relationships
runs = db.relationship('Run', backref='project', lazy=True, cascade="all, delete-orphan")
class Run(db.Model):
id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False)
status = db.Column(db.String(50), default="queued") # queued, running, completed, failed
status = db.Column(db.String(50), default="queued")
start_time = db.Column(db.DateTime, default=datetime.utcnow)
end_time = db.Column(db.DateTime, nullable=True)
log_file = db.Column(db.String(300), nullable=True)
cost = db.Column(db.Float, default=0.0)
progress = db.Column(db.Integer, default=0)
# Relationships
logs = db.relationship('LogEntry', backref='run', lazy=True, cascade="all, delete-orphan")
def duration(self):
@@ -40,6 +41,7 @@ class Run(db.Model):
return str(self.end_time - self.start_time).split('.')[0]
return "Running..."
class LogEntry(db.Model):
id = db.Column(db.Integer, primary_key=True)
run_id = db.Column(db.Integer, db.ForeignKey('run.id'), nullable=False)

25
web/helpers.py Normal file
View File

@@ -0,0 +1,25 @@
from functools import wraps
from urllib.parse import urlparse, urljoin
from flask import redirect, url_for, flash, request
from flask_login import current_user
from web.db import Run
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or not current_user.is_admin:
flash("Admin access required.")
return redirect(url_for('project.index'))
return f(*args, **kwargs)
return decorated_function
def is_project_locked(project_id):
return Run.query.filter_by(project_id=project_id, status='completed').count() > 0
def is_safe_url(target):
ref_url = urlparse(request.host_url)
test_url = urlparse(urljoin(request.host_url, target))
return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc

0
web/routes/__init__.py Normal file
View File

226
web/routes/admin.py Normal file
View File

@@ -0,0 +1,226 @@
import os
import json
import shutil
from datetime import datetime, timedelta
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from flask_login import login_required, login_user, current_user
from sqlalchemy import func
from web.db import db, User, Project, Run
from web.helpers import admin_required
from core import config, utils
from ai import models as ai_models
from ai import setup as ai_setup
from story import style_persona, bible_tracker
admin_bp = Blueprint('admin', __name__)
@admin_bp.route('/admin')
@login_required
@admin_required
def admin_dashboard():
users = User.query.all()
projects = Project.query.all()
return render_template('admin_dashboard.html', users=users, projects=projects)
@admin_bp.route('/admin/user/<int:user_id>/delete', methods=['POST'])
@login_required
@admin_required
def admin_delete_user(user_id):
if user_id == current_user.id:
flash("Cannot delete yourself.")
return redirect(url_for('admin.admin_dashboard'))
user = db.session.get(User, user_id)
if user:
user_path = os.path.join(config.DATA_DIR, "users", str(user.id))
if os.path.exists(user_path):
try: shutil.rmtree(user_path)
except: pass
projects = Project.query.filter_by(user_id=user.id).all()
for p in projects:
db.session.delete(p)
db.session.delete(user)
db.session.commit()
flash(f"User {user.username} deleted.")
return redirect(url_for('admin.admin_dashboard'))
@admin_bp.route('/admin/project/<int:project_id>/delete', methods=['POST'])
@login_required
@admin_required
def admin_delete_project(project_id):
proj = db.session.get(Project, project_id)
if proj:
if os.path.exists(proj.folder_path):
try: shutil.rmtree(proj.folder_path)
except: pass
db.session.delete(proj)
db.session.commit()
flash(f"Project {proj.name} deleted.")
return redirect(url_for('admin.admin_dashboard'))
@admin_bp.route('/admin/reset', methods=['POST'])
@login_required
@admin_required
def admin_factory_reset():
projects = Project.query.all()
for p in projects:
if os.path.exists(p.folder_path):
try: shutil.rmtree(p.folder_path)
except: pass
db.session.delete(p)
users = User.query.filter(User.id != current_user.id).all()
for u in users:
user_path = os.path.join(config.DATA_DIR, "users", str(u.id))
if os.path.exists(user_path):
try: shutil.rmtree(user_path)
except: pass
db.session.delete(u)
if os.path.exists(config.PERSONAS_FILE):
try: os.remove(config.PERSONAS_FILE)
except: pass
utils.create_default_personas()
db.session.commit()
flash("Factory Reset Complete. All other users and projects have been wiped.")
return redirect(url_for('admin.admin_dashboard'))
@admin_bp.route('/admin/spend')
@login_required
@admin_required
def admin_spend_report():
days = request.args.get('days', 30, type=int)
if days > 0:
start_date = datetime.utcnow() - timedelta(days=days)
else:
start_date = datetime.min
results = db.session.query(
User.username,
func.count(Run.id),
func.sum(Run.cost)
).join(Project, Project.user_id == User.id)\
.join(Run, Run.project_id == Project.id)\
.filter(Run.start_time >= start_date)\
.group_by(User.id, User.username).all()
report = []
total_period_spend = 0.0
for r in results:
cost = r[2] if r[2] else 0.0
report.append({"username": r[0], "runs": r[1], "cost": cost})
total_period_spend += cost
return render_template('admin_spend.html', report=report, days=days, total=total_period_spend)
@admin_bp.route('/admin/style', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_style_guidelines():
path = os.path.join(config.DATA_DIR, "style_guidelines.json")
if request.method == 'POST':
ai_isms_raw = request.form.get('ai_isms', '')
filter_words_raw = request.form.get('filter_words', '')
data = {
"ai_isms": [x.strip() for x in ai_isms_raw.split('\n') if x.strip()],
"filter_words": [x.strip() for x in filter_words_raw.split('\n') if x.strip()]
}
with open(path, 'w') as f: json.dump(data, f, indent=2)
flash("Style Guidelines updated successfully.")
return redirect(url_for('admin.admin_style_guidelines'))
data = style_persona.get_style_guidelines()
return render_template('admin_style.html', data=data)
@admin_bp.route('/admin/impersonate/<int:user_id>')
@login_required
@admin_required
def impersonate_user(user_id):
if user_id == current_user.id:
flash("Cannot impersonate yourself.")
return redirect(url_for('admin.admin_dashboard'))
user = db.session.get(User, user_id)
if user:
session['original_admin_id'] = current_user.id
login_user(user)
flash(f"Now viewing as {user.username}")
return redirect(url_for('project.index'))
return redirect(url_for('admin.admin_dashboard'))
@admin_bp.route('/admin/stop_impersonate')
@login_required
def stop_impersonate():
admin_id = session.get('original_admin_id')
if admin_id:
admin = db.session.get(User, admin_id)
if admin:
login_user(admin)
session.pop('original_admin_id', None)
flash("Restored admin session.")
return redirect(url_for('admin.admin_dashboard'))
return redirect(url_for('project.index'))
@admin_bp.route('/debug/routes')
@login_required
@admin_required
def debug_routes():
from flask import current_app
output = []
for rule in current_app.url_map.iter_rules():
methods = ','.join(rule.methods)
rule_str = str(rule).replace('<', '[').replace('>', ']')
line = "{:50s} {:20s} {}".format(rule.endpoint, methods, rule_str)
output.append(line)
return "<pre>" + "\n".join(output) + "</pre>"
@admin_bp.route('/system/optimize_models', methods=['POST'])
@login_required
@admin_required
def optimize_models():
try:
ai_setup.init_models(force=True)
if ai_models.model_logic:
style_persona.refresh_style_guidelines(ai_models.model_logic)
flash("AI Models refreshed and Style Guidelines updated.")
except Exception as e:
flash(f"Error refreshing models: {e}")
return redirect(request.referrer or url_for('project.index'))
@admin_bp.route('/system/status')
@login_required
def system_status():
models_info = {}
cache_data = {}
cache_path = os.path.join(config.DATA_DIR, "model_cache.json")
if os.path.exists(cache_path):
try:
with open(cache_path, 'r') as f:
cache_data = json.load(f)
models_info = cache_data.get('models', {})
except: pass
return render_template('system_status.html', models=models_info, cache=cache_data, datetime=datetime,
image_model=ai_models.image_model_name, image_source=ai_models.image_model_source)

57
web/routes/auth.py Normal file
View File

@@ -0,0 +1,57 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from flask_login import login_user, login_required, logout_user, current_user
from werkzeug.security import generate_password_hash, check_password_hash
from sqlalchemy.exc import IntegrityError
from web.db import db, User
from web.helpers import is_safe_url
from core import config
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
user = User.query.filter_by(username=username).first()
if user and check_password_hash(user.password, password):
login_user(user)
next_page = request.args.get('next')
if not next_page or not is_safe_url(next_page):
next_page = url_for('project.index')
return redirect(next_page)
if user and user.is_admin:
print(f"⚠️ System: Admin login failed for '{username}'. Password hash mismatch.")
flash('Invalid credentials')
return render_template('login.html')
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if User.query.filter_by(username=username).first():
flash('Username exists')
return redirect(url_for('auth.register'))
new_user = User(username=username, password=generate_password_hash(password, method='pbkdf2:sha256'))
if config.ADMIN_USER and username == config.ADMIN_USER:
new_user.is_admin = True
try:
db.session.add(new_user)
db.session.commit()
login_user(new_user)
return redirect(url_for('project.index'))
except IntegrityError:
db.session.rollback()
flash('Username exists')
return redirect(url_for('auth.register'))
return render_template('register.html')
@auth_bp.route('/logout')
def logout():
logout_user()
return redirect(url_for('auth.login'))

135
web/routes/persona.py Normal file
View File

@@ -0,0 +1,135 @@
import os
import json
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_required
from core import config, utils
from ai import models as ai_models
from ai import setup as ai_setup
persona_bp = Blueprint('persona', __name__)
@persona_bp.route('/personas')
@login_required
def list_personas():
personas = {}
if os.path.exists(config.PERSONAS_FILE):
try:
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
except: pass
return render_template('personas.html', personas=personas)
@persona_bp.route('/persona/new')
@login_required
def new_persona():
return render_template('persona_edit.html', persona={}, name="")
@persona_bp.route('/persona/<string:name>')
@login_required
def edit_persona(name):
personas = {}
if os.path.exists(config.PERSONAS_FILE):
try:
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
except: pass
persona = personas.get(name)
if not persona:
flash(f"Persona '{name}' not found.")
return redirect(url_for('persona.list_personas'))
return render_template('persona_edit.html', persona=persona, name=name)
@persona_bp.route('/persona/save', methods=['POST'])
@login_required
def save_persona():
old_name = request.form.get('old_name')
name = request.form.get('name')
if not name:
flash("Persona name is required.")
return redirect(url_for('persona.list_personas'))
personas = {}
if os.path.exists(config.PERSONAS_FILE):
try:
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
except: pass
if old_name and old_name != name and old_name in personas:
del personas[old_name]
persona = {
"name": name,
"bio": request.form.get('bio'),
"age": request.form.get('age'),
"gender": request.form.get('gender'),
"race": request.form.get('race'),
"nationality": request.form.get('nationality'),
"language": request.form.get('language'),
"sample_text": request.form.get('sample_text'),
"voice_keywords": request.form.get('voice_keywords'),
"style_inspirations": request.form.get('style_inspirations')
}
personas[name] = persona
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2)
flash(f"Persona '{name}' saved.")
return redirect(url_for('persona.list_personas'))
@persona_bp.route('/persona/delete/<string:name>', methods=['POST'])
@login_required
def delete_persona(name):
personas = {}
if os.path.exists(config.PERSONAS_FILE):
try:
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
except: pass
if name in personas:
del personas[name]
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2)
flash(f"Persona '{name}' deleted.")
return redirect(url_for('persona.list_personas'))
@persona_bp.route('/persona/analyze', methods=['POST'])
@login_required
def analyze_persona():
try: ai_setup.init_models()
except: pass
if not ai_models.model_logic:
return {"error": "AI models not initialized."}, 500
data = request.json
sample = data.get('sample_text', '')
prompt = f"""
ROLE: Literary Analyst
TASK: Create or analyze an Author Persona profile.
INPUT_DATA:
- NAME: {data.get('name')}
- DEMOGRAPHICS: Age: {data.get('age')} | Gender: {data.get('gender')} | Nationality: {data.get('nationality')}
- SAMPLE_TEXT: {sample[:3000]}
INSTRUCTIONS:
1. BIO: Write a 2-3 sentence description of the writing style. If sample is provided, analyze it. If not, invent a style that fits the demographics/name.
2. KEYWORDS: Comma-separated list of 3-5 adjectives describing the voice (e.g. Gritty, Whimsical, Sarcastic).
3. INSPIRATIONS: Comma-separated list of 1-3 famous authors or genres that this style resembles.
OUTPUT_FORMAT (JSON): {{ "bio": "String", "voice_keywords": "String", "style_inspirations": "String" }}
"""
try:
response = ai_models.model_logic.generate_content(prompt)
return json.loads(utils.clean_json(response.text))
except Exception as e:
return {"error": str(e)}, 500

760
web/routes/project.py Normal file
View File

@@ -0,0 +1,760 @@
import os
import json
import shutil
from datetime import datetime
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from web.db import db, Project, Run
from web.helpers import is_project_locked
from core import config, utils
from ai import models as ai_models
from ai import setup as ai_setup
from story import planner, bible_tracker
from web.tasks import generate_book_task, refine_bible_task
project_bp = Blueprint('project', __name__)
@project_bp.route('/')
@login_required
def index():
projects = Project.query.filter_by(user_id=current_user.id).all()
return render_template('dashboard.html', projects=projects, user=current_user)
@project_bp.route('/project/setup', methods=['POST'])
@login_required
def project_setup_wizard():
concept = request.form.get('concept')
try: ai_setup.init_models()
except: pass
if not ai_models.model_logic:
flash("AI models not initialized.")
return redirect(url_for('project.index'))
prompt = f"""
ROLE: Publishing Analyst
TASK: Suggest metadata for a story concept.
CONCEPT: {concept}
OUTPUT_FORMAT (JSON):
{{
"title": "String",
"genre": "String",
"target_audience": "String",
"tone": "String",
"length_category": "String (Select code: '01'=Chapter Book, '1'=Flash Fiction, '2'=Short Story, '2b'=Young Adult, '3'=Novella, '4'=Novel, '5'=Epic)",
"estimated_chapters": Int,
"estimated_word_count": "String (e.g. '75,000')",
"include_prologue": Bool,
"include_epilogue": Bool,
"tropes": ["String"],
"pov_style": "String",
"time_period": "String",
"spice": "String",
"violence": "String",
"is_series": Bool,
"series_title": "String",
"narrative_tense": "String",
"language_style": "String",
"dialogue_style": "String",
"page_orientation": "Portrait|Landscape|Square",
"formatting_rules": ["String (e.g. 'Chapter Headers: Number + Title')"],
"author_bio": "String"
}}
"""
suggestions = {}
try:
response = ai_models.model_logic.generate_content(prompt)
suggestions = json.loads(utils.clean_json(response.text))
except Exception as e:
flash(f"AI Analysis failed: {e}")
suggestions = {"title": "New Project", "genre": "Fiction"}
personas = {}
if os.path.exists(config.PERSONAS_FILE):
try:
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
except: pass
return render_template('project_setup.html', s=suggestions, concept=concept, personas=personas, lengths=config.LENGTH_DEFINITIONS)
@project_bp.route('/project/setup/refine', methods=['POST'])
@login_required
def project_setup_refine():
concept = request.form.get('concept')
instruction = request.form.get('refine_instruction')
current_state = {
"title": request.form.get('title'),
"genre": request.form.get('genre'),
"target_audience": request.form.get('audience'),
"tone": request.form.get('tone'),
}
try: ai_setup.init_models()
except: pass
prompt = f"""
ROLE: Publishing Analyst
TASK: Refine project metadata based on user instruction.
INPUT_DATA:
- ORIGINAL_CONCEPT: {concept}
- CURRENT_TITLE: {current_state['title']}
- INSTRUCTION: {instruction}
OUTPUT_FORMAT (JSON): Same structure as the initial analysis (title, genre, length_category, etc). Ensure length_category matches the word count.
"""
suggestions = {}
try:
response = ai_models.model_logic.generate_content(prompt)
suggestions = json.loads(utils.clean_json(response.text))
except Exception as e:
flash(f"Refinement failed: {e}")
return redirect(url_for('project.index'))
personas = {}
if os.path.exists(config.PERSONAS_FILE):
try:
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
except: pass
return render_template('project_setup.html', s=suggestions, concept=concept, personas=personas, lengths=config.LENGTH_DEFINITIONS)
@project_bp.route('/project/create', methods=['POST'])
@login_required
def create_project_final():
title = request.form.get('title')
safe_title = utils.sanitize_filename(title)
user_dir = os.path.join(config.DATA_DIR, "users", str(current_user.id))
os.makedirs(user_dir, exist_ok=True)
proj_path = os.path.join(user_dir, safe_title)
if os.path.exists(proj_path):
safe_title += f"_{int(datetime.utcnow().timestamp())}"
proj_path = os.path.join(user_dir, safe_title)
os.makedirs(proj_path, exist_ok=True)
length_cat = request.form.get('length_category')
len_def = config.LENGTH_DEFINITIONS.get(length_cat, config.LENGTH_DEFINITIONS['4']).copy()
try: len_def['chapters'] = int(request.form.get('chapters'))
except: pass
len_def['words'] = request.form.get('words')
len_def['include_prologue'] = 'include_prologue' in request.form
len_def['include_epilogue'] = 'include_epilogue' in request.form
is_series = 'is_series' in request.form
style = {
"tone": request.form.get('tone'),
"pov_style": request.form.get('pov_style'),
"time_period": request.form.get('time_period'),
"spice": request.form.get('spice'),
"violence": request.form.get('violence'),
"narrative_tense": request.form.get('narrative_tense'),
"language_style": request.form.get('language_style'),
"dialogue_style": request.form.get('dialogue_style'),
"page_orientation": request.form.get('page_orientation'),
"tropes": [x.strip() for x in request.form.get('tropes', '').split(',') if x.strip()],
"formatting_rules": [x.strip() for x in request.form.get('formatting_rules', '').split(',') if x.strip()]
}
bible = {
"project_metadata": {
"title": title,
"author": request.form.get('author'),
"author_bio": request.form.get('author_bio'),
"genre": request.form.get('genre'),
"target_audience": request.form.get('audience'),
"is_series": is_series,
"length_settings": len_def,
"style": style
},
"books": [],
"characters": []
}
count = 1
if is_series:
try: count = int(request.form.get('series_count', 1))
except: count = 3
concept = request.form.get('concept', '')
for i in range(count):
bible['books'].append({
"book_number": i+1,
"title": f"{title} - Book {i+1}" if is_series else title,
"manual_instruction": concept if i==0 else "",
"plot_beats": []
})
try:
ai_setup.init_models()
bible = planner.enrich(bible, proj_path)
except: pass
with open(os.path.join(proj_path, "bible.json"), 'w') as f:
json.dump(bible, f, indent=2)
new_proj = Project(user_id=current_user.id, name=title, folder_path=proj_path)
db.session.add(new_proj)
db.session.commit()
return redirect(url_for('project.view_project', id=new_proj.id))
@project_bp.route('/project/import', methods=['POST'])
@login_required
def import_project():
if 'bible_file' not in request.files:
flash('No file part')
return redirect(url_for('project.index'))
file = request.files['bible_file']
if file.filename == '':
flash('No selected file')
return redirect(url_for('project.index'))
if file:
try:
bible = json.load(file)
if 'project_metadata' not in bible or 'title' not in bible['project_metadata']:
flash("Invalid Bible format: Missing project_metadata or title.")
return redirect(url_for('project.index'))
title = bible['project_metadata']['title']
safe_title = utils.sanitize_filename(title)
user_dir = os.path.join(config.DATA_DIR, "users", str(current_user.id))
os.makedirs(user_dir, exist_ok=True)
proj_path = os.path.join(user_dir, safe_title)
if os.path.exists(proj_path):
safe_title += f"_{int(datetime.utcnow().timestamp())}"
proj_path = os.path.join(user_dir, safe_title)
os.makedirs(proj_path)
with open(os.path.join(proj_path, "bible.json"), 'w') as f:
json.dump(bible, f, indent=2)
new_proj = Project(user_id=current_user.id, name=title, folder_path=proj_path)
db.session.add(new_proj)
db.session.commit()
flash(f"Project '{title}' imported successfully.")
return redirect(url_for('project.view_project', id=new_proj.id))
except Exception as e:
flash(f"Import failed: {str(e)}")
return redirect(url_for('project.index'))
@project_bp.route('/project/<int:id>')
@login_required
def view_project(id):
proj = db.session.get(Project, id)
if not proj: return "Project not found", 404
if proj.user_id != current_user.id: return "Unauthorized", 403
bible_path = os.path.join(proj.folder_path, "bible.json")
bible_data = utils.load_json(bible_path)
draft_path = os.path.join(proj.folder_path, "bible_draft.json")
has_draft = os.path.exists(draft_path)
is_refining = os.path.exists(os.path.join(proj.folder_path, ".refining"))
personas = {}
if os.path.exists(config.PERSONAS_FILE):
try:
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
except: pass
runs = Run.query.filter_by(project_id=id).order_by(Run.id.desc()).all()
latest_run = runs[0] if runs else None
other_projects = Project.query.filter(Project.user_id == current_user.id, Project.id != id).all()
artifacts = []
cover_image = None
generated_books = {}
locked = is_project_locked(id)
for r in runs:
if r.status == 'completed':
run_dir = os.path.join(proj.folder_path, "runs", f"run_{r.id}")
if os.path.exists(run_dir):
for d in os.listdir(run_dir):
if d.startswith("Book_") and os.path.isdir(os.path.join(run_dir, d)):
if os.path.exists(os.path.join(run_dir, d, "manuscript.json")):
try:
parts = d.split('_')
if len(parts) > 1 and parts[1].isdigit():
b_num = int(parts[1])
if b_num not in generated_books:
book_path = os.path.join(run_dir, d)
epub_file = next((f for f in os.listdir(book_path) if f.endswith('.epub')), None)
docx_file = next((f for f in os.listdir(book_path) if f.endswith('.docx')), None)
generated_books[b_num] = {'status': 'generated', 'run_id': r.id, 'folder': d, 'epub': os.path.join(d, epub_file).replace("\\", "/") if epub_file else None, 'docx': os.path.join(d, docx_file).replace("\\", "/") if docx_file else None}
except: pass
if latest_run:
run_dir = os.path.join(proj.folder_path, "runs", f"run_{latest_run.id}")
if os.path.exists(run_dir):
if os.path.exists(os.path.join(run_dir, "cover.png")):
cover_image = "cover.png"
else:
subdirs = utils.get_sorted_book_folders(run_dir)
for d in subdirs:
if os.path.exists(os.path.join(run_dir, d, "cover.png")):
cover_image = os.path.join(d, "cover.png").replace("\\", "/")
break
for root, dirs, files in os.walk(run_dir):
for f in files:
if f.lower().endswith(('.epub', '.docx')):
rel_path = os.path.relpath(os.path.join(root, f), run_dir)
artifacts.append({
'name': f,
'path': rel_path.replace("\\", "/"),
'type': f.split('.')[-1].upper()
})
return render_template('project.html', project=proj, bible=bible_data, runs=runs, active_run=latest_run, artifacts=artifacts, cover_image=cover_image, personas=personas, generated_books=generated_books, other_projects=other_projects, locked=locked, has_draft=has_draft, is_refining=is_refining)
@project_bp.route('/project/<int:id>/run', methods=['POST'])
@login_required
def run_project(id):
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
new_run = Run(project_id=id, status="queued")
db.session.add(new_run)
db.session.commit()
bible_path = os.path.join(proj.folder_path, "bible.json")
generate_book_task(new_run.id, proj.folder_path, bible_path, allow_copy=True)
return redirect(url_for('project.view_project', id=id))
@project_bp.route('/project/<int:id>/review')
@login_required
def review_project(id):
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
if proj.user_id != current_user.id: return "Unauthorized", 403
bible_path = os.path.join(proj.folder_path, "bible.json")
bible = utils.load_json(bible_path)
return render_template('project_review.html', project=proj, bible=bible)
@project_bp.route('/project/<int:id>/update', methods=['POST'])
@login_required
def update_project_metadata(id):
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
if proj.user_id != current_user.id: return "Unauthorized", 403
if is_project_locked(id):
flash("Project is locked. Clone it to make changes.")
return redirect(url_for('project.view_project', id=id))
new_title = request.form.get('title')
new_author = request.form.get('author')
bible_path = os.path.join(proj.folder_path, "bible.json")
bible = utils.load_json(bible_path)
if bible:
if new_title:
bible['project_metadata']['title'] = new_title
proj.name = new_title
if new_author:
bible['project_metadata']['author'] = new_author
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
db.session.commit()
return redirect(url_for('project.view_project', id=id))
@project_bp.route('/project/<int:id>/clone', methods=['POST'])
@login_required
def clone_project(id):
source_proj = db.session.get(Project, id) or Project.query.get_or_404(id)
if source_proj.user_id != current_user.id: return "Unauthorized", 403
new_name = request.form.get('new_name')
instruction = request.form.get('instruction')
safe_title = utils.sanitize_filename(new_name)
user_dir = os.path.join(config.DATA_DIR, "users", str(current_user.id))
new_path = os.path.join(user_dir, safe_title)
if os.path.exists(new_path):
safe_title += f"_{int(datetime.utcnow().timestamp())}"
new_path = os.path.join(user_dir, safe_title)
os.makedirs(new_path)
source_bible_path = os.path.join(source_proj.folder_path, "bible.json")
if os.path.exists(source_bible_path):
bible = utils.load_json(source_bible_path)
bible['project_metadata']['title'] = new_name
if instruction:
try:
ai_setup.init_models()
bible = bible_tracker.refine_bible(bible, instruction, new_path) or bible
except: pass
with open(os.path.join(new_path, "bible.json"), 'w') as f: json.dump(bible, f, indent=2)
new_proj = Project(user_id=current_user.id, name=new_name, folder_path=new_path)
db.session.add(new_proj)
db.session.commit()
flash(f"Project cloned as '{new_name}'.")
return redirect(url_for('project.view_project', id=new_proj.id))
@project_bp.route('/project/<int:id>/bible_comparison')
@login_required
def bible_comparison(id):
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
if proj.user_id != current_user.id: return "Unauthorized", 403
bible_path = os.path.join(proj.folder_path, "bible.json")
draft_path = os.path.join(proj.folder_path, "bible_draft.json")
if not os.path.exists(draft_path):
flash("No draft found. Please refine the bible first.")
return redirect(url_for('project.review_project', id=id))
original = utils.load_json(bible_path)
new_draft = utils.load_json(draft_path)
if not original or not new_draft:
flash("Error loading bible data. Draft may be corrupt.")
return redirect(url_for('project.review_project', id=id))
return render_template('bible_comparison.html', project=proj, original=original, new=new_draft)
@project_bp.route('/project/<int:id>/refine_bible', methods=['POST'])
@login_required
def refine_bible_route(id):
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
if proj.user_id != current_user.id: return "Unauthorized", 403
if is_project_locked(id):
flash("Project is locked. Clone it to make changes.")
return redirect(url_for('project.view_project', id=id))
data = request.json if request.is_json else request.form
instruction = data.get('instruction')
if not instruction:
return {"error": "Instruction required"}, 400
source_type = data.get('source', 'original')
selected_keys = data.get('selected_keys')
if isinstance(selected_keys, str):
try: selected_keys = json.loads(selected_keys) if selected_keys.strip() else []
except: selected_keys = []
task = refine_bible_task(proj.folder_path, instruction, source_type, selected_keys)
return {"status": "queued", "task_id": task.id}
@project_bp.route('/project/<int:id>/is_refining')
@login_required
def check_refinement_status(id):
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
if proj.user_id != current_user.id: return "Unauthorized", 403
is_refining = os.path.exists(os.path.join(proj.folder_path, ".refining"))
return {"is_refining": is_refining}
@project_bp.route('/project/<int:id>/refine_bible/confirm', methods=['POST'])
@login_required
def confirm_bible_refinement(id):
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
if proj.user_id != current_user.id: return "Unauthorized", 403
action = request.form.get('action')
draft_path = os.path.join(proj.folder_path, "bible_draft.json")
bible_path = os.path.join(proj.folder_path, "bible.json")
if action == 'accept' or action == 'accept_all':
if os.path.exists(draft_path):
shutil.move(draft_path, bible_path)
flash("Bible updated successfully.")
else:
flash("Draft expired or missing.")
elif action == 'accept_selected':
if os.path.exists(draft_path) and os.path.exists(bible_path):
selected_keys_json = request.form.get('selected_keys', '[]')
try:
selected_keys = json.loads(selected_keys_json)
draft = utils.load_json(draft_path)
original = utils.load_json(bible_path)
original = bible_tracker.merge_selected_changes(original, draft, selected_keys)
with open(bible_path, 'w') as f: json.dump(original, f, indent=2)
os.remove(draft_path)
flash(f"Merged {len(selected_keys)} changes into Bible.")
except Exception as e:
flash(f"Merge failed: {e}")
else:
flash("Files missing.")
elif action == 'decline':
if os.path.exists(draft_path):
os.remove(draft_path)
flash("Changes discarded.")
return redirect(url_for('project.view_project', id=id))
@project_bp.route('/project/<int:id>/add_book', methods=['POST'])
@login_required
def add_book(id):
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
if proj.user_id != current_user.id: return "Unauthorized", 403
if is_project_locked(id):
flash("Project is locked. Clone it to make changes.")
return redirect(url_for('project.view_project', id=id))
title = request.form.get('title', 'Untitled')
instruction = request.form.get('instruction', '')
bible_path = os.path.join(proj.folder_path, "bible.json")
bible = utils.load_json(bible_path)
if bible:
if 'books' not in bible: bible['books'] = []
next_num = len(bible['books']) + 1
new_book = {
"book_number": next_num,
"title": title,
"manual_instruction": instruction,
"plot_beats": []
}
bible['books'].append(new_book)
if 'project_metadata' in bible:
bible['project_metadata']['is_series'] = True
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
flash(f"Added Book {next_num}: {title}")
return redirect(url_for('project.view_project', id=id))
@project_bp.route('/project/<int:id>/book/<int:book_num>/update', methods=['POST'])
@login_required
def update_book_details(id, book_num):
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
if proj.user_id != current_user.id: return "Unauthorized", 403
if is_project_locked(id):
flash("Project is locked. Clone it to make changes.")
return redirect(url_for('project.view_project', id=id))
new_title = request.form.get('title')
new_instruction = request.form.get('instruction')
bible_path = os.path.join(proj.folder_path, "bible.json")
bible = utils.load_json(bible_path)
if bible and 'books' in bible:
for b in bible['books']:
if b.get('book_number') == book_num:
if new_title: b['title'] = new_title
if new_instruction is not None: b['manual_instruction'] = new_instruction
break
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
flash(f"Book {book_num} updated.")
return redirect(url_for('project.view_project', id=id))
@project_bp.route('/project/<int:id>/delete_book/<int:book_num>', methods=['POST'])
@login_required
def delete_book(id, book_num):
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
if proj.user_id != current_user.id: return "Unauthorized", 403
if is_project_locked(id):
flash("Project is locked. Clone it to make changes.")
return redirect(url_for('project.view_project', id=id))
bible_path = os.path.join(proj.folder_path, "bible.json")
bible = utils.load_json(bible_path)
if bible and 'books' in bible:
bible['books'] = [b for b in bible['books'] if b.get('book_number') != book_num]
for i, b in enumerate(bible['books']):
b['book_number'] = i + 1
if 'project_metadata' in bible:
bible['project_metadata']['is_series'] = (len(bible['books']) > 1)
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
flash("Book deleted from plan.")
return redirect(url_for('project.view_project', id=id))
@project_bp.route('/project/<int:id>/import_characters', methods=['POST'])
@login_required
def import_characters(id):
target_proj = db.session.get(Project, id)
source_id = request.form.get('source_project_id')
source_proj = db.session.get(Project, source_id)
if not target_proj or not source_proj: return "Project not found", 404
if target_proj.user_id != current_user.id or source_proj.user_id != current_user.id: return "Unauthorized", 403
if is_project_locked(id):
flash("Project is locked. Clone it to make changes.")
return redirect(url_for('project.view_project', id=id))
target_bible = utils.load_json(os.path.join(target_proj.folder_path, "bible.json"))
source_bible = utils.load_json(os.path.join(source_proj.folder_path, "bible.json"))
if target_bible and source_bible:
existing_names = {c['name'].lower() for c in target_bible.get('characters', [])}
added_count = 0
for char in source_bible.get('characters', []):
if char['name'].lower() not in existing_names:
target_bible['characters'].append(char)
added_count += 1
if added_count > 0:
with open(os.path.join(target_proj.folder_path, "bible.json"), 'w') as f:
json.dump(target_bible, f, indent=2)
flash(f"Imported {added_count} characters from {source_proj.name}.")
else:
flash("No new characters found to import.")
return redirect(url_for('project.view_project', id=id))
@project_bp.route('/project/<int:id>/set_persona', methods=['POST'])
@login_required
def set_project_persona(id):
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
if proj.user_id != current_user.id: return "Unauthorized", 403
if is_project_locked(id):
flash("Project is locked. Clone it to make changes.")
return redirect(url_for('project.view_project', id=id))
persona_name = request.form.get('persona_name')
bible_path = os.path.join(proj.folder_path, "bible.json")
bible = utils.load_json(bible_path)
if bible:
personas = {}
if os.path.exists(config.PERSONAS_FILE):
try:
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
except: pass
if persona_name in personas:
bible['project_metadata']['author_details'] = personas[persona_name]
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
flash(f"Project voice updated to persona: {persona_name}")
else:
flash("Persona not found.")
return redirect(url_for('project.view_project', id=id))
@project_bp.route('/run/<int:id>/stop', methods=['POST'])
@login_required
def stop_run(id):
run = db.session.get(Run, id) or Run.query.get_or_404(id)
if run.project.user_id != current_user.id: return "Unauthorized", 403
if run.status in ['queued', 'running']:
run.status = 'cancelled'
run.end_time = datetime.utcnow()
db.session.commit()
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
if os.path.exists(run_dir):
with open(os.path.join(run_dir, ".stop"), 'w') as f: f.write("stop")
flash(f"Run {id} marked as cancelled.")
return redirect(url_for('project.view_project', id=run.project_id))
@project_bp.route('/run/<int:id>/restart', methods=['POST'])
@login_required
def restart_run(id):
run = db.session.get(Run, id) or Run.query.get_or_404(id)
if run.project.user_id != current_user.id: return "Unauthorized", 403
new_run = Run(project_id=run.project_id, status="queued")
db.session.add(new_run)
db.session.commit()
mode = request.form.get('mode', 'resume')
feedback = request.form.get('feedback')
keep_cover = 'keep_cover' in request.form
force_regen = 'force_regenerate' in request.form
allow_copy = (mode == 'resume' and not force_regen)
if feedback: allow_copy = False
generate_book_task(new_run.id, run.project.folder_path, os.path.join(run.project.folder_path, "bible.json"), allow_copy=allow_copy, feedback=feedback, source_run_id=id if feedback else None, keep_cover=keep_cover)
flash(f"Started new Run #{new_run.id}" + (" with modifications." if feedback else "."))
return redirect(url_for('project.view_project', id=run.project_id))
@project_bp.route('/project/<int:run_id>/revise_book/<string:book_folder>', methods=['POST'])
@login_required
def revise_book(run_id, book_folder):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id: return "Unauthorized", 403
instruction = request.form.get('instruction')
new_run = Run(project_id=run.project_id, status="queued")
db.session.add(new_run)
db.session.commit()
generate_book_task(
new_run.id,
run.project.folder_path,
os.path.join(run.project.folder_path, "bible.json"),
allow_copy=True,
feedback=instruction,
source_run_id=run.id,
keep_cover=True,
exclude_folders=[book_folder]
)
flash(f"Started Revision Run #{new_run.id}. Book '{book_folder}' will be regenerated.")
return redirect(url_for('project.view_project', id=run.project_id))

335
web/routes/run.py Normal file
View File

@@ -0,0 +1,335 @@
import os
import json
import markdown
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, send_from_directory
from flask_login import login_required, current_user
from web.db import db, Run, LogEntry
from core import utils
from ai import models as ai_models
from ai import setup as ai_setup
from story import editor as story_editor
from story import bible_tracker, style_persona
from export import exporter
from web.tasks import huey, regenerate_artifacts_task, rewrite_chapter_task
run_bp = Blueprint('run', __name__)
@run_bp.route('/run/<int:id>')
@login_required
def view_run(id):
run = db.session.get(Run, id)
if not run: return "Run not found", 404
if run.project.user_id != current_user.id: return "Unauthorized", 403
log_content = ""
logs = LogEntry.query.filter_by(run_id=id).order_by(LogEntry.timestamp).all()
if logs:
log_content = "\n".join([f"[{l.timestamp.strftime('%H:%M:%S')}] {l.phase:<15} | {l.message}" for l in logs])
elif run.log_file and os.path.exists(run.log_file):
with open(run.log_file, 'r') as f: log_content = f.read()
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
books_data = []
if os.path.exists(run_dir):
subdirs = utils.get_sorted_book_folders(run_dir)
for d in subdirs:
b_path = os.path.join(run_dir, d)
b_info = {'folder': d, 'artifacts': [], 'cover': None, 'blurb': ''}
for f in os.listdir(b_path):
if f.lower().endswith(('.epub', '.docx')):
b_info['artifacts'].append({'name': f, 'path': os.path.join(d, f).replace("\\", "/")})
if os.path.exists(os.path.join(b_path, "cover.png")):
b_info['cover'] = os.path.join(d, "cover.png").replace("\\", "/")
blurb_p = os.path.join(b_path, "blurb.txt")
if os.path.exists(blurb_p):
with open(blurb_p, 'r', encoding='utf-8', errors='ignore') as f: b_info['blurb'] = f.read()
books_data.append(b_info)
bible_path = os.path.join(run.project.folder_path, "bible.json")
bible_data = utils.load_json(bible_path)
tracking = {"events": [], "characters": {}, "content_warnings": []}
book_dir = os.path.join(run_dir, books_data[-1]['folder']) if books_data else run_dir
if os.path.exists(book_dir):
t_ev = os.path.join(book_dir, "tracking_events.json")
t_ch = os.path.join(book_dir, "tracking_characters.json")
t_wn = os.path.join(book_dir, "tracking_warnings.json")
if os.path.exists(t_ev): tracking['events'] = utils.load_json(t_ev) or []
if os.path.exists(t_ch): tracking['characters'] = utils.load_json(t_ch) or {}
if os.path.exists(t_wn): tracking['content_warnings'] = utils.load_json(t_wn) or []
return render_template('run_details.html', run=run, log_content=log_content, books=books_data, bible=bible_data, tracking=tracking)
@run_bp.route('/run/<int:id>/status')
@login_required
def run_status(id):
run = db.session.get(Run, id) or Run.query.get_or_404(id)
log_content = ""
last_log = None
logs = LogEntry.query.filter_by(run_id=id).order_by(LogEntry.timestamp).all()
if logs:
log_content = "\n".join([f"[{l.timestamp.strftime('%H:%M:%S')}] {l.phase:<15} | {l.message}" for l in logs])
last_log = logs[-1]
if not log_content:
if run.log_file and os.path.exists(run.log_file):
with open(run.log_file, 'r') as f: log_content = f.read()
elif run.status in ['queued', 'running']:
temp_log = os.path.join(run.project.folder_path, f"system_log_{run.id}.txt")
if os.path.exists(temp_log):
with open(temp_log, 'r') as f: log_content = f.read()
response = {
"status": run.status,
"log": log_content,
"cost": run.cost,
"percent": run.progress,
"start_time": run.start_time.timestamp() if run.start_time else None
}
if last_log:
response["progress"] = {
"phase": last_log.phase,
"message": last_log.message,
"timestamp": last_log.timestamp.timestamp()
}
return response
@run_bp.route('/project/<int:run_id>/download')
@login_required
def download_artifact(run_id):
filename = request.args.get('file')
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id: return "Unauthorized", 403
if not filename: return "Missing filename", 400
if os.path.isabs(filename) or ".." in os.path.normpath(filename) or ":" in filename:
return "Invalid filename", 400
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
if not os.path.exists(os.path.join(run_dir, filename)) and os.path.exists(run_dir):
subdirs = utils.get_sorted_book_folders(run_dir)
for d in subdirs:
possible_path = os.path.join(d, filename)
if os.path.exists(os.path.join(run_dir, possible_path)):
filename = possible_path
break
return send_from_directory(run_dir, filename, as_attachment=True)
@run_bp.route('/project/<int:run_id>/read/<string:book_folder>')
@login_required
def read_book(run_id, book_folder):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id: return "Unauthorized", 403
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
book_path = os.path.join(run_dir, book_folder)
ms_path = os.path.join(book_path, "manuscript.json")
if not os.path.exists(ms_path):
flash("Manuscript not found.")
return redirect(url_for('run.view_run', id=run_id))
manuscript = utils.load_json(ms_path)
manuscript.sort(key=utils.chapter_sort_key)
for ch in manuscript:
ch['html_content'] = markdown.markdown(ch.get('content', ''))
return render_template('read_book.html', run=run, book_folder=book_folder, manuscript=manuscript)
@run_bp.route('/project/<int:run_id>/save_chapter', methods=['POST'])
@login_required
def save_chapter(run_id):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id: return "Unauthorized", 403
if run.status == 'running':
return "Cannot edit chapter while run is active.", 409
book_folder = request.form.get('book_folder')
chap_num_raw = request.form.get('chapter_num')
try: chap_num = int(chap_num_raw)
except: chap_num = chap_num_raw
new_content = request.form.get('content')
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
ms_path = os.path.join(run_dir, book_folder, "manuscript.json")
if os.path.exists(ms_path):
ms = utils.load_json(ms_path)
for ch in ms:
if str(ch.get('num')) == str(chap_num):
ch['content'] = new_content
break
with open(ms_path, 'w') as f: json.dump(ms, f, indent=2)
book_path = os.path.join(run_dir, book_folder)
bp_path = os.path.join(book_path, "final_blueprint.json")
if os.path.exists(bp_path):
bp = utils.load_json(bp_path)
exporter.compile_files(bp, ms, book_path)
return "Saved", 200
return "Error", 500
@run_bp.route('/project/<int:run_id>/check_consistency/<string:book_folder>')
@login_required
def check_consistency(run_id, book_folder):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
book_path = os.path.join(run_dir, book_folder)
bp = utils.load_json(os.path.join(book_path, "final_blueprint.json"))
ms = utils.load_json(os.path.join(book_path, "manuscript.json"))
if not bp or not ms:
return "Data files missing or corrupt.", 404
try: ai_setup.init_models()
except: pass
report = story_editor.analyze_consistency(bp, ms, book_path)
return render_template('consistency_report.html', report=report, run=run, book_folder=book_folder)
@run_bp.route('/project/<int:run_id>/sync_book/<string:book_folder>', methods=['POST'])
@login_required
def sync_book_metadata(run_id, book_folder):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id: return "Unauthorized", 403
if run.status == 'running':
flash("Cannot sync metadata while run is active.")
return redirect(url_for('run.read_book', run_id=run_id, book_folder=book_folder))
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
book_path = os.path.join(run_dir, book_folder)
ms_path = os.path.join(book_path, "manuscript.json")
bp_path = os.path.join(book_path, "final_blueprint.json")
if os.path.exists(ms_path) and os.path.exists(bp_path):
ms = utils.load_json(ms_path)
bp = utils.load_json(bp_path)
if not ms or not bp:
flash("Data files corrupt.")
return redirect(url_for('run.read_book', run_id=run_id, book_folder=book_folder))
try: ai_setup.init_models()
except: pass
bp = bible_tracker.harvest_metadata(bp, book_path, ms)
tracking_path = os.path.join(book_path, "tracking_characters.json")
if os.path.exists(tracking_path):
tracking_chars = utils.load_json(tracking_path) or {}
updated_tracking = False
for c in bp.get('characters', []):
if c.get('name') and c['name'] not in tracking_chars:
tracking_chars[c['name']] = {"descriptors": [c.get('description', '')], "likes_dislikes": [], "last_worn": "Unknown"}
updated_tracking = True
if updated_tracking:
with open(tracking_path, 'w') as f: json.dump(tracking_chars, f, indent=2)
style_persona.update_persona_sample(bp, book_path)
with open(bp_path, 'w') as f: json.dump(bp, f, indent=2)
flash("Metadata synced. Future generations will respect your edits.")
else:
flash("Files not found.")
return redirect(url_for('run.read_book', run_id=run_id, book_folder=book_folder))
@run_bp.route('/project/<int:run_id>/rewrite_chapter', methods=['POST'])
@login_required
def rewrite_chapter(run_id):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id:
return {"error": "Unauthorized"}, 403
if run.status == 'running':
return {"error": "Cannot rewrite while run is active."}, 409
data = request.json
book_folder = data.get('book_folder')
chap_num = data.get('chapter_num')
instruction = data.get('instruction')
if not book_folder or chap_num is None or not instruction:
return {"error": "Missing parameters"}, 400
if "/" in book_folder or "\\" in book_folder or ".." in book_folder: return {"error": "Invalid book folder"}, 400
try: chap_num = int(chap_num)
except: pass
task = rewrite_chapter_task(run.id, run.project.folder_path, book_folder, chap_num, instruction)
session['rewrite_task_id'] = task.id
return {"status": "queued", "task_id": task.id}, 202
@run_bp.route('/task_status/<string:task_id>')
@login_required
def get_task_status(task_id):
try:
task_result = huey.result(task_id, preserve=True)
except Exception as e:
return {"status": "completed", "success": False, "error": str(e)}
if task_result is None:
return {"status": "running"}
else:
return {"status": "completed", "success": task_result}
@run_bp.route('/project/<int:run_id>/regenerate_artifacts', methods=['POST'])
@login_required
def regenerate_artifacts(run_id):
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
if run.project.user_id != current_user.id: return "Unauthorized", 403
if run.status == 'running':
flash("Run is already active. Please wait for it to finish.")
return redirect(url_for('run.view_run', id=run_id))
feedback = request.form.get('feedback')
run.status = 'queued'
db.session.commit()
regenerate_artifacts_task(run_id, run.project.folder_path, feedback=feedback)
flash("Regenerating cover and files with updated metadata...")
return redirect(url_for('run.view_run', id=run_id))

View File

@@ -5,10 +5,13 @@ import sqlite3
import shutil
from datetime import datetime
from huey import SqliteHuey
from .web_db import db, Run, User, Project
from . import utils
import config
from . import story, ai, marketing, export
from web.db import db, Run, User, Project
from core import utils, config
from ai import models as ai_models
from ai import setup as ai_setup
from story import bible_tracker
from marketing import cover as marketing_cover
from export import exporter
# Configure Huey (Task Queue)
huey = SqliteHuey('bookapp_queue', filename=os.path.join(config.DATA_DIR, 'queue.db'))
@@ -43,7 +46,7 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
# 1. Setup Logging
log_filename = f"system_log_{run_id}.txt"
# Log to project root initially until run folder is created by main
# Log to project root initially until run folder is created by engine
initial_log = os.path.join(project_path, log_filename)
utils.set_log_file(initial_log)
@@ -65,62 +68,51 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
if feedback and source_run_id:
utils.log("SYSTEM", f"Applying feedback to Run #{source_run_id}: '{feedback}'")
# Load Source Data (Prefer final_blueprint from source run to capture its state)
source_run_dir = os.path.join(project_path, "runs", f"run_{source_run_id}")
bible_data = utils.load_json(bible_path)
# Try to find the blueprint of the book in the source run
# (Simplification: If multiple books, we apply feedback to the Bible generally)
if bible_data:
try:
ai.init_models()
new_bible = story.refine_bible(bible_data, feedback, project_path)
ai_setup.init_models()
new_bible = bible_tracker.refine_bible(bible_data, feedback, project_path)
if new_bible:
bible_data = new_bible
# Save updated Bible (This updates the project state to the new "fork")
with open(bible_path, 'w') as f: json.dump(bible_data, f, indent=2)
utils.log("SYSTEM", "Bible updated with feedback.")
except Exception as e:
utils.log("ERROR", f"Failed to refine bible: {e}")
# 1.2 Keep Cover Art Logic
if keep_cover and os.path.exists(source_run_dir):
utils.log("SYSTEM", "Attempting to preserve cover art...")
if keep_cover:
source_run_dir = os.path.join(project_path, "runs", f"run_{source_run_id}")
if os.path.exists(source_run_dir):
utils.log("SYSTEM", "Attempting to preserve cover art...")
# We need to predict the new folder names to place the covers
# main.py uses: Book_{n}_{safe_title}
current_run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
if not os.path.exists(current_run_dir): os.makedirs(current_run_dir)
current_run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
if not os.path.exists(current_run_dir): os.makedirs(current_run_dir)
# Map Source Books -> Target Books by Book Number
source_books = {}
for d in os.listdir(source_run_dir):
if d.startswith("Book_") and os.path.isdir(os.path.join(source_run_dir, d)):
parts = d.split('_')
if len(parts) > 1 and parts[1].isdigit():
source_books[int(parts[1])] = os.path.join(source_run_dir, d)
source_books = {}
for d in os.listdir(source_run_dir):
if d.startswith("Book_") and os.path.isdir(os.path.join(source_run_dir, d)):
parts = d.split('_')
if len(parts) > 1 and parts[1].isdigit():
source_books[int(parts[1])] = os.path.join(source_run_dir, d)
if bible_data and 'books' in bible_data:
for i, book in enumerate(bible_data['books']):
b_num = book.get('book_number', i+1)
if b_num in source_books:
# Found matching book in source
src_folder = source_books[b_num]
if bible_data and 'books' in bible_data:
for i, book in enumerate(bible_data['books']):
b_num = book.get('book_number', i+1)
if b_num in source_books:
src_folder = source_books[b_num]
safe_title = utils.sanitize_filename(book.get('title', f"Book_{b_num}"))
target_folder = os.path.join(current_run_dir, f"Book_{b_num}_{safe_title}")
# Predict Target Folder
safe_title = utils.sanitize_filename(book.get('title', f"Book_{b_num}"))
target_folder = os.path.join(current_run_dir, f"Book_{b_num}_{safe_title}")
os.makedirs(target_folder, exist_ok=True)
os.makedirs(target_folder, exist_ok=True)
# Copy Cover
src_cover = os.path.join(src_folder, "cover.png")
if os.path.exists(src_cover):
shutil.copy2(src_cover, os.path.join(target_folder, "cover.png"))
# Also copy cover_art.png to prevent regeneration if logic allows
if os.path.exists(os.path.join(src_folder, "cover_art.png")):
shutil.copy2(os.path.join(src_folder, "cover_art.png"), os.path.join(target_folder, "cover_art.png"))
utils.log("SYSTEM", f" -> Copied cover for Book {b_num}")
src_cover = os.path.join(src_folder, "cover.png")
if os.path.exists(src_cover):
shutil.copy2(src_cover, os.path.join(target_folder, "cover.png"))
if os.path.exists(os.path.join(src_folder, "cover_art.png")):
shutil.copy2(os.path.join(src_folder, "cover_art.png"), os.path.join(target_folder, "cover_art.png"))
utils.log("SYSTEM", f" -> Copied cover for Book {b_num}")
# 1.5 Copy Forward Logic (Series Optimization)
is_series = False
@@ -131,11 +123,8 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
runs_dir = os.path.join(project_path, "runs")
# Only copy if explicitly requested AND it's a series (Standalone books get fresh re-rolls)
if allow_copy and is_series and os.path.exists(runs_dir):
# Get all run folders except current
all_runs = [d for d in os.listdir(runs_dir) if d.startswith("run_") and d != f"run_{run_id}"]
# Sort by ID (ascending)
all_runs.sort(key=lambda x: int(x.split('_')[1]) if x.split('_')[1].isdigit() else 0)
if all_runs:
@@ -145,7 +134,6 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
utils.log("SYSTEM", f"Checking previous run ({all_runs[-1]}) for completed books...")
for item in os.listdir(latest_run_dir):
# Copy only folders that look like books and have a manuscript
if item.startswith("Book_") and os.path.isdir(os.path.join(latest_run_dir, item)):
if exclude_folders and item in exclude_folders:
utils.log("SYSTEM", f" -> Skipping copy of {item} (Target for revision).")
@@ -161,8 +149,7 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
utils.log("SYSTEM", f" -> Failed to copy {item}: {e}")
# 2. Run Generation
# We call the existing entry point
from main import run_generation
from cli.engine import run_generation
run_generation(bible_path, specific_run_id=run_id)
utils.log("SYSTEM", "Job Complete.")
@@ -174,25 +161,20 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
status = "failed"
# 3. Calculate Cost & Cleanup
# Use the specific run folder we know main.py used
run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
total_cost = 0.0
final_log_path = initial_log
if os.path.exists(run_dir):
# Move our log file there
final_log_path = os.path.join(run_dir, "web_console.log")
if os.path.exists(initial_log):
try:
os.rename(initial_log, final_log_path)
except OSError:
# If rename fails (e.g. across filesystems), copy and delete
shutil.copy2(initial_log, final_log_path)
os.remove(initial_log)
# Calculate Total Cost from all Book subfolders
# usage_log.json is inside each Book folder
for item in os.listdir(run_dir):
item_path = os.path.join(run_dir, item)
if os.path.isdir(item_path) and item.startswith("Book_"):
@@ -211,39 +193,32 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
return {"run_id": run_id, "status": status, "cost": total_cost, "final_log": final_log_path}
@huey.task()
def regenerate_artifacts_task(run_id, project_path, feedback=None):
# Hook up Database Logging & Status
db_path = os.path.join(config.DATA_DIR, "bookapp.db")
# Determine log file path: Prefer the existing web_console.log in the run dir
run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
log_file = os.path.join(run_dir, "web_console.log")
# Fallback to project root temp file if run dir doesn't exist (unlikely for regeneration)
if not os.path.exists(run_dir):
log_file = os.path.join(project_path, f"system_log_{run_id}.txt")
# Clear previous logs (File) to refresh the log window
try:
with open(log_file, 'w', encoding='utf-8') as f:
f.write(f"[{datetime.utcnow().strftime('%H:%M:%S')}] --- REGENERATION STARTED ---\n")
except: pass
utils.set_log_file(log_file)
utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m))
try:
with sqlite3.connect(db_path) as conn:
conn.execute("DELETE FROM log_entry WHERE run_id = ?", (run_id,)) # Clear DB logs
conn.execute("DELETE FROM log_entry WHERE run_id = ?", (run_id,))
conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,))
except: pass
utils.log("SYSTEM", "Starting Artifact Regeneration...")
# 1. Setup Paths
# Detect Book Subfolder
book_dir = run_dir
if os.path.exists(run_dir):
subdirs = utils.get_sorted_book_folders(run_dir)
@@ -259,7 +234,6 @@ def regenerate_artifacts_task(run_id, project_path, feedback=None):
except: pass
return
# 2. Load Data
bible = utils.load_json(bible_path)
final_bp_path = os.path.join(book_dir, "final_blueprint.json")
ms_path = os.path.join(book_dir, "manuscript.json")
@@ -275,17 +249,14 @@ def regenerate_artifacts_task(run_id, project_path, feedback=None):
bp = utils.load_json(final_bp_path)
ms = utils.load_json(ms_path)
# 3. Update Blueprint with new Metadata from Bible
meta = bible.get('project_metadata', {})
if 'book_metadata' in bp:
# Sync all core metadata
for k in ['author', 'genre', 'target_audience', 'style']:
if k in meta:
bp['book_metadata'][k] = meta[k]
if bp.get('series_metadata', {}).get('is_series'):
bp['series_metadata']['series_title'] = meta.get('title', bp['series_metadata'].get('series_title'))
# Find specific book title from Bible
b_num = bp['series_metadata'].get('book_number')
for b in bible.get('books', []):
if b.get('book_number') == b_num:
@@ -296,17 +267,16 @@ def regenerate_artifacts_task(run_id, project_path, feedback=None):
with open(final_bp_path, 'w') as f: json.dump(bp, f, indent=2)
# 4. Regenerate
try:
ai.init_models()
ai_setup.init_models()
tracking = None
events_path = os.path.join(book_dir, "tracking_events.json")
if os.path.exists(events_path):
tracking = {"events": utils.load_json(events_path), "characters": utils.load_json(os.path.join(book_dir, "tracking_characters.json"))}
marketing.generate_cover(bp, book_dir, tracking, feedback=feedback)
export.compile_files(bp, ms, book_dir)
marketing_cover.generate_cover(bp, book_dir, tracking, feedback=feedback)
exporter.compile_files(bp, ms, book_dir)
utils.log("SYSTEM", "Regeneration Complete.")
final_status = 'completed'
@@ -319,6 +289,7 @@ def regenerate_artifacts_task(run_id, project_path, feedback=None):
conn.execute("UPDATE run SET status = ? WHERE id = ?", (final_status, run_id))
except: pass
@huey.task()
def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instruction):
"""
@@ -327,12 +298,10 @@ def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instructio
try:
run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
# --- Setup Logging for Rewrite ---
log_file = os.path.join(run_dir, "web_console.log")
if not os.path.exists(log_file):
log_file = os.path.join(project_path, f"system_log_{run_id}.txt")
# Clear previous logs to refresh the log window
try:
with open(log_file, 'w', encoding='utf-8') as f: f.write("")
except: pass
@@ -346,10 +315,8 @@ def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instructio
conn.execute("DELETE FROM log_entry WHERE run_id = ?", (run_id,))
conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,))
except: pass
# ---------------------------------
book_path = os.path.join(run_dir, book_folder)
ms_path = os.path.join(book_path, "manuscript.json")
bp_path = os.path.join(book_path, "final_blueprint.json")
@@ -360,9 +327,10 @@ def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instructio
ms = utils.load_json(ms_path)
bp = utils.load_json(bp_path)
ai.init_models()
ai_setup.init_models()
result = story.rewrite_chapter_content(bp, ms, chap_num, instruction, book_path)
from story import editor as story_editor
result = story_editor.rewrite_chapter_content(bp, ms, chap_num, instruction, book_path)
if result and result[0]:
new_text, summary = result
@@ -371,15 +339,14 @@ def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instructio
ch['content'] = new_text
break
# Save the primary rewrite immediately
with open(ms_path, 'w') as f: json.dump(ms, f, indent=2)
updated_ms = story.check_and_propagate(bp, ms, chap_num, book_path, change_summary=summary)
updated_ms = story_editor.check_and_propagate(bp, ms, chap_num, book_path, change_summary=summary)
if updated_ms:
ms = updated_ms
with open(ms_path, 'w') as f: json.dump(ms, f, indent=2)
export.compile_files(bp, ms, book_path)
exporter.compile_files(bp, ms, book_path)
try:
with sqlite3.connect(db_path) as conn:
@@ -387,7 +354,6 @@ def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instructio
except: pass
return True
# If result is False/None
try:
with sqlite3.connect(db_path) as conn:
conn.execute("UPDATE run SET status = 'completed' WHERE id = ?", (run_id,))
@@ -401,6 +367,7 @@ def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instructio
except: pass
return False
@huey.task()
def refine_bible_task(project_path, instruction, source_type, selected_keys=None):
"""
@@ -417,25 +384,19 @@ def refine_bible_task(project_path, instruction, source_type, selected_keys=None
base_bible = utils.load_json(bible_path)
if not base_bible: return False
# If refining from draft, load it
if source_type == 'draft' and os.path.exists(draft_path):
draft_bible = utils.load_json(draft_path)
# If user selected specific changes, merge them into the base
# This creates a "Proposed State" to refine further, WITHOUT modifying bible.json
if selected_keys is not None and draft_bible:
base_bible = story.merge_selected_changes(base_bible, draft_bible, selected_keys)
base_bible = bible_tracker.merge_selected_changes(base_bible, draft_bible, selected_keys)
elif draft_bible:
# If no specific keys but source is draft, assume we refine the whole draft
base_bible = draft_bible
ai.init_models()
ai_setup.init_models()
# Run AI Refinement
new_bible = story.refine_bible(base_bible, instruction, project_path)
new_bible = bible_tracker.refine_bible(base_bible, instruction, project_path)
if new_bible:
# Save to draft file (Overwrite previous draft)
with open(draft_path, 'w') as f: json.dump(new_bible, f, indent=2)
return True