Compare commits

...

2 Commits

Author SHA1 Message Date
81353cf071 Add AI artifact entries to .gitignore
Appended entries from ai_blueprint.md guidelines to exclude AI planning
files, context indexes, and assistant directories from version control:
- ai_blueprint.md and plans/
- .claude/, .gemini/, .roo/, .cline/, .cursor/, .cascade/, .windsurfrules
- *.aiindex, ai_workspace_index.json

Also untracks the already-committed .claude/ and ai_blueprint.md files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 22:25:38 -05:00
f7099cc3e4 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>
2026-02-20 22:20:53 -05:00
51 changed files with 3685 additions and 3799 deletions

26
.gitignore vendored
View File

@@ -5,4 +5,28 @@ run_*/
*.epub *.epub
data/ data/
token.json token.json
credentials.json credentials.json
# AI Blueprint and Context Files
ai_blueprint.md
plans/
# Claude / Anthropic Artifacts
.claude/
claude.json
# Gemini / Google Artifacts
.gemini/
gemini_history.json
# AI Coding Assistant Directories (Roo Code, Cline, Cursor, Windsurf)
.roo/
.cline/
.cursor/
.cursorrules
.windsurfrules
.cascade/
# AI Generated Index and Memory Cache Files
*.aiindex
ai_workspace_index.json

View File

@@ -14,11 +14,11 @@ RUN apt-get update && apt-get install -y \
# Copy requirements files # Copy requirements files
COPY requirements.txt . COPY requirements.txt .
COPY modules/requirements_web.txt ./modules/ COPY web/requirements_web.txt ./web/
# Install dependencies # Install dependencies
RUN pip install --no-cache-dir -r requirements.txt 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 the rest of the application
COPY . . COPY . .
@@ -28,4 +28,4 @@ ENV PYTHONPATH=/app
EXPOSE 5000 EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ 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 -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 time
import warnings import warnings
import google.generativeai as genai import google.generativeai as genai
import config from core import config, utils
from . import 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"): def get_optimal_model(base_type="pro"):
try: try:
models = [m for m in genai.list_models() if 'generateContent' in m.supported_generation_methods] available = [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] candidates = [m.name for m in available if base_type in m.name]
if not candidates: return f"models/gemini-1.5-{base_type}" if not candidates: return f"models/gemini-1.5-{base_type}"
def score(n): def score(n):
# Prefer newer generations: 2.5 > 2.0 > 1.5
gen_bonus = 0 gen_bonus = 0
if "2.5" in n: gen_bonus = 300 if "2.5" in n: gen_bonus = 300
elif "2.0" in n: gen_bonus = 200 elif "2.0" in n: gen_bonus = 200
elif "2." in n: gen_bonus = 150 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 "exp" in n or "beta" in n or "preview" in n: return gen_bonus + 0
if "latest" in n: return gen_bonus + 50 if "latest" in n: return gen_bonus + 50
return gen_bonus + 100 return gen_bonus + 100
return sorted(candidates, key=score, reverse=True)[0] return sorted(candidates, key=score, reverse=True)[0]
except Exception as e: except Exception as e:
utils.log("SYSTEM", f"⚠️ Error finding optimal model: {e}") utils.log("SYSTEM", f"⚠️ Error finding optimal model: {e}")
return f"models/gemini-1.5-{base_type}" return f"models/gemini-1.5-{base_type}"
def get_default_models(): def get_default_models():
return { 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)"}, "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": [] "ranking": []
} }
def select_best_models(force_refresh=False): 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") cache_path = os.path.join(config.DATA_DIR, "model_cache.json")
cached_models = None cached_models = None
# 1. Check Cache
if os.path.exists(cache_path): if os.path.exists(cache_path):
try: try:
with open(cache_path, 'r') as f: with open(cache_path, 'r') as f:
cached = json.load(f) cached = json.load(f)
cached_models = cached.get('models', {}) cached_models = cached.get('models', {})
# Check if within 24 hours (86400 seconds)
if not force_refresh and time.time() - cached.get('timestamp', 0) < 86400: if not force_refresh and time.time() - cached.get('timestamp', 0) < 86400:
models = cached_models m = cached_models
# Validate format (must be dicts with reasons, not just strings) if isinstance(m.get('logic'), dict) and 'reason' in m['logic']:
if isinstance(models.get('logic'), dict) and 'reason' in models['logic']:
utils.log("SYSTEM", "Using cached AI model selection (valid for 24h).") utils.log("SYSTEM", "Using cached AI model selection (valid for 24h).")
return models return m
except Exception as e: except Exception as e:
utils.log("SYSTEM", f"Cache read failed: {e}. Refreshing models.") utils.log("SYSTEM", f"Cache read failed: {e}. Refreshing models.")
@@ -128,20 +59,20 @@ def select_best_models(force_refresh=False):
all_models = list(genai.list_models()) all_models = list(genai.list_models())
raw_model_names = [m.name for m in all_models] raw_model_names = [m.name for m in all_models]
utils.log("SYSTEM", f"Found {len(all_models)} raw models from Google API.") 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()] 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(models)} compatible Gemini models: {models}") utils.log("SYSTEM", f"Identified {len(compatible)} compatible Gemini models: {compatible}")
bootstrapper = get_optimal_model("flash") bootstrapper = get_optimal_model("flash")
utils.log("SYSTEM", f"Bootstrapping model selection with: {bootstrapper}") utils.log("SYSTEM", f"Bootstrapping model selection with: {bootstrapper}")
model = genai.GenerativeModel(bootstrapper) model = genai.GenerativeModel(bootstrapper)
prompt = f""" prompt = f"""
ROLE: AI Model Architect ROLE: AI Model Architect
TASK: Select the optimal Gemini models for a book-writing application. Prefer newer Gemini 2.x models when available. TASK: Select the optimal Gemini models for a book-writing application. Prefer newer Gemini 2.x models when available.
AVAILABLE_MODELS: AVAILABLE_MODELS:
{json.dumps(models)} {json.dumps(compatible)}
PRICING_CONTEXT (USD per 1M tokens, approximate): PRICING_CONTEXT (USD per 1M tokens, approximate):
- Gemini 2.5 Pro/Flash: Best quality/speed; check current pricing. - Gemini 2.5 Pro/Flash: Best quality/speed; check current pricing.
@@ -172,49 +103,47 @@ def select_best_models(force_refresh=False):
"ranking": [ {{ "model": "string", "reason": "string", "estimated_cost": "string" }} ] "ranking": [ {{ "model": "string", "reason": "string", "estimated_cost": "string" }} ]
}} }}
""" """
try: try:
response = model.generate_content(prompt) response = model.generate_content(prompt)
selection = json.loads(utils.clean_json(response.text)) selection = json.loads(utils.clean_json(response.text))
except Exception as e: except Exception as e:
utils.log("SYSTEM", f"Model selection generation failed (Safety/Format): {e}") utils.log("SYSTEM", f"Model selection generation failed (Safety/Format): {e}")
raise e raise e
if not os.path.exists(config.DATA_DIR): os.makedirs(config.DATA_DIR) if not os.path.exists(config.DATA_DIR): os.makedirs(config.DATA_DIR)
with open(cache_path, 'w') as f: with open(cache_path, 'w') as f:
json.dump({ json.dump({
"timestamp": int(time.time()), "timestamp": int(time.time()),
"models": selection, "models": selection,
"available_at_time": models, "available_at_time": compatible,
"raw_models": raw_model_names "raw_models": raw_model_names
}, f, indent=2) }, f, indent=2)
return selection return selection
except Exception as e: except Exception as e:
utils.log("SYSTEM", f"AI Model Selection failed: {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: if cached_models:
utils.log("SYSTEM", "⚠️ Using stale cached models due to API failure.") utils.log("SYSTEM", "⚠️ Using stale cached models due to API failure.")
return cached_models return cached_models
utils.log("SYSTEM", "Falling back to heuristics.") utils.log("SYSTEM", "Falling back to heuristics.")
fallback = get_default_models() 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: try:
with open(cache_path, 'w') as f: with open(cache_path, 'w') as f:
json.dump({"timestamp": int(time.time()), "models": fallback, "error": str(e)}, f, indent=2) json.dump({"timestamp": int(time.time()), "models": fallback, "error": str(e)}, f, indent=2)
except: pass except: pass
return fallback return fallback
def init_models(force=False): 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 global_vars = models.__dict__
if model_logic and not force: return if global_vars.get('model_logic') and not force: return
genai.configure(api_key=config.API_KEY) genai.configure(api_key=config.API_KEY)
# Check cache to skip frequent validation
cache_path = os.path.join(config.DATA_DIR, "model_cache.json") cache_path = os.path.join(config.DATA_DIR, "model_cache.json")
skip_validation = False skip_validation = False
if not force and os.path.exists(cache_path): if not force and os.path.exists(cache_path):
@@ -224,22 +153,19 @@ def init_models(force=False):
except: pass except: pass
if not skip_validation: if not skip_validation:
# Validate Gemini API Key
utils.log("SYSTEM", "Validating credentials...") utils.log("SYSTEM", "Validating credentials...")
try: try:
list(genai.list_models(page_size=1)) list(genai.list_models(page_size=1))
utils.log("SYSTEM", "✅ Gemini API Key is valid.") utils.log("SYSTEM", "✅ Gemini API Key is valid.")
except Exception as e: except Exception as e:
# Check if we have a cache file we can rely on before exiting
if os.path.exists(cache_path): 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: else:
utils.log("SYSTEM", f"⚠️ API check failed ({e}). No cache found. Attempting to initialize with defaults.") utils.log("SYSTEM", f"⚠️ API check failed ({e}). No cache found. Attempting to initialize with defaults.")
utils.log("SYSTEM", "Selecting optimal models via AI...") utils.log("SYSTEM", "Selecting optimal models via AI...")
selected_models = select_best_models(force_refresh=force) selected_models = select_best_models(force_refresh=force)
# Check for missing costs and force refresh if needed
if not force: if not force:
missing_costs = False missing_costs = False
for role in ['logic', 'writer', 'artist']: 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']) writer_name, writer_cost = get_model_details(selected_models['writer'])
artist_name, artist_cost = get_model_details(selected_models['artist']) 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 logic_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 writer_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 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}") 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(logic_name, logic_cost)
utils.update_pricing(writer_name, writer_cost) utils.update_pricing(writer_name, writer_cost)
utils.update_pricing(artist_name, artist_cost) utils.update_pricing(artist_name, artist_cost)
# Initialize or Update Resilient Models if models.model_logic is None:
if model_logic is None: models.model_logic = models.ResilientModel(logic_name, utils.SAFETY_SETTINGS, "Logic")
model_logic = ResilientModel(logic_name, utils.SAFETY_SETTINGS, "Logic") models.model_writer = models.ResilientModel(writer_name, utils.SAFETY_SETTINGS, "Writer")
model_writer = ResilientModel(writer_name, utils.SAFETY_SETTINGS, "Writer") models.model_artist = models.ResilientModel(artist_name, utils.SAFETY_SETTINGS, "Artist")
model_artist = ResilientModel(artist_name, utils.SAFETY_SETTINGS, "Artist")
else: else:
# If models already exist (re-init), update them in place models.model_logic.update(logic_name)
model_logic.update(logic_name) models.model_writer.update(writer_name)
model_writer.update(writer_name) models.model_artist.update(artist_name)
model_artist.update(artist_name)
models.model_image = None
# Initialize Image Model models.image_model_name = None
model_image = None models.image_model_source = "None"
image_model_name = None
image_model_source = "None"
hint = config.MODEL_IMAGE_HINT if hasattr(config, 'MODEL_IMAGE_HINT') else "AUTO" hint = config.MODEL_IMAGE_HINT if hasattr(config, 'MODEL_IMAGE_HINT') else "AUTO"
if hasattr(genai, 'ImageGenerationModel'): if hasattr(genai, 'ImageGenerationModel'):
# Candidate image models in preference order candidates = [hint] if hint and hint != "AUTO" else ["imagen-3.0-generate-001", "imagen-3.0-fast-generate-001"]
if hint and hint != "AUTO":
candidates = [hint]
else:
candidates = ["imagen-3.0-generate-001", "imagen-3.0-fast-generate-001"]
for candidate in candidates: for candidate in candidates:
try: try:
model_image = genai.ImageGenerationModel(candidate) models.model_image = genai.ImageGenerationModel(candidate)
image_model_name = candidate models.image_model_name = candidate
image_model_source = "Gemini API" models.image_model_source = "Gemini API"
utils.log("SYSTEM", f"✅ Image model: {candidate} (Gemini API)") utils.log("SYSTEM", f"✅ Image model: {candidate} (Gemini API)")
break break
except Exception: except Exception:
continue continue
# Auto-detect GCP Project from credentials if not set (Fix for Image Model) # Auto-detect GCP Project
if HAS_VERTEX and not config.GCP_PROJECT and config.GOOGLE_CREDS and os.path.exists(config.GOOGLE_CREDS): if models.HAS_VERTEX and not config.GCP_PROJECT and config.GOOGLE_CREDS and os.path.exists(config.GOOGLE_CREDS):
try: try:
with open(config.GOOGLE_CREDS, 'r') as f: with open(config.GOOGLE_CREDS, 'r') as f:
cdata = json.load(f) cdata = json.load(f)
# Check common OAuth structures
for k in ['installed', 'web']: for k in ['installed', 'web']:
if k in cdata and 'project_id' in cdata[k]: if k in cdata and 'project_id' in cdata[k]:
config.GCP_PROJECT = cdata[k]['project_id'] config.GCP_PROJECT = cdata[k]['project_id']
@@ -315,58 +236,54 @@ def init_models(force=False):
break break
except: pass except: pass
if HAS_VERTEX and config.GCP_PROJECT: if models.HAS_VERTEX and config.GCP_PROJECT:
creds = None creds = None
# Handle OAuth Client ID (credentials.json) if provided instead of Service Account if models.HAS_OAUTH:
if HAS_OAUTH: gac = config.GOOGLE_CREDS
gac = config.GOOGLE_CREDS # Use persistent config, not volatile env var
if gac and os.path.exists(gac): if gac and os.path.exists(gac):
try: try:
with open(gac, 'r') as f: data = json.load(f) with open(gac, 'r') as f: data = json.load(f)
if 'installed' in data or 'web' in data: 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: if "GOOGLE_APPLICATION_CREDENTIALS" in os.environ:
del os.environ["GOOGLE_APPLICATION_CREDENTIALS"] del os.environ["GOOGLE_APPLICATION_CREDENTIALS"]
token_path = os.path.join(os.path.dirname(os.path.abspath(gac)), 'token.json') token_path = os.path.join(os.path.dirname(os.path.abspath(gac)), 'token.json')
SCOPES = ['https://www.googleapis.com/auth/cloud-platform'] SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
if os.path.exists(token_path): 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 not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token: if creds and creds.expired and creds.refresh_token:
try: try:
creds.refresh(Request()) creds.refresh(models.Request())
except Exception: except Exception:
utils.log("SYSTEM", "Token refresh failed. Re-authenticating...") 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) creds = flow.run_local_server(port=0)
else: else:
utils.log("SYSTEM", "OAuth Client ID detected. Launching browser to authenticate...") 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) creds = flow.run_local_server(port=0)
with open(token_path, 'w') as token: token.write(creds.to_json()) with open(token_path, 'w') as token: token.write(creds.to_json())
utils.log("SYSTEM", "✅ Authenticated via OAuth Client ID.") utils.log("SYSTEM", "✅ Authenticated via OAuth Client ID.")
except Exception as e: except Exception as e:
utils.log("SYSTEM", f"⚠️ OAuth check failed: {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})") utils.log("SYSTEM", f"✅ Vertex AI initialized (Project: {config.GCP_PROJECT})")
# Override with Vertex Image Model if available vertex_candidates = [hint] if hint and hint != "AUTO" else ["imagen-3.0-generate-001", "imagen-3.0-fast-generate-001"]
vertex_candidates = ["imagen-3.0-generate-001", "imagen-3.0-fast-generate-001"]
if hint and hint != "AUTO":
vertex_candidates = [hint]
for candidate in vertex_candidates: for candidate in vertex_candidates:
try: try:
model_image = VertexImageModel.from_pretrained(candidate) models.model_image = models.VertexImageModel.from_pretrained(candidate)
image_model_name = candidate models.image_model_name = candidate
image_model_source = "Vertex AI" models.image_model_source = "Vertex AI"
utils.log("SYSTEM", f"✅ Image model: {candidate} (Vertex AI)") utils.log("SYSTEM", f"✅ Image model: {candidate} (Vertex AI)")
break break
except Exception: except Exception:
continue 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'})")

0
cli/__init__.py Normal file
View File

View File

@@ -1,21 +1,29 @@
import json, os, time, sys, shutil import json
import config import os
import time
import sys
import shutil
from rich.prompt import Confirm 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): def process_book(bp, folder, context="", resume=False, interactive=False):
# Create lock file to indicate active processing # Create lock file to indicate active processing
lock_path = os.path.join(folder, ".in_progress") lock_path = os.path.join(folder, ".in_progress")
with open(lock_path, "w") as f: f.write("running") with open(lock_path, "w") as f: f.write("running")
total_start = time.time() total_start = time.time()
try: try:
# 1. Check completion # 1. Check completion
if resume and os.path.exists(os.path.join(folder, "final_blueprint.json")): if resume and os.path.exists(os.path.join(folder, "final_blueprint.json")):
utils.log("SYSTEM", f"Book in {folder} already finished. Skipping.") 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) if os.path.exists(lock_path): os.remove(lock_path)
return return
@@ -26,7 +34,6 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
if resume and os.path.exists(bp_path): if resume and os.path.exists(bp_path):
utils.log("RESUME", "Loading existing blueprint...") utils.log("RESUME", "Loading existing blueprint...")
saved_bp = utils.load_json(bp_path) saved_bp = utils.load_json(bp_path)
# Merge latest metadata from Bible (passed in bp) into saved blueprint
if saved_bp: if saved_bp:
if 'book_metadata' in bp and 'book_metadata' in 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']: for k in ['title', 'author', 'genre', 'target_audience', 'style', 'author_bio', 'author_details']:
@@ -37,16 +44,16 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
bp = saved_bp bp = saved_bp
with open(bp_path, "w") as f: json.dump(bp, f, indent=2) with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
else: 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) with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
# Ensure Persona Exists (Auto-create if missing) # Ensure Persona Exists (Auto-create if missing)
if 'author_details' not in bp['book_metadata'] or not bp['book_metadata']['author_details']: 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) 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") utils.log("TIMING", f"Blueprint Phase: {time.time() - t_step:.1f}s")
# 3. Events (Plan & Expand) # 3. Events (Plan & Expand)
events_path = os.path.join(folder, "events.json") events_path = os.path.join(folder, "events.json")
t_step = time.time() t_step = time.time()
@@ -55,15 +62,15 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
utils.log("RESUME", "Loading existing events...") utils.log("RESUME", "Loading existing events...")
events = utils.load_json(events_path) events = utils.load_json(events_path)
else: else:
events = story.plan_structure(bp, folder) events = planner.plan_structure(bp, folder)
depth = bp['length_settings']['depth'] depth = bp['length_settings']['depth']
target_chaps = bp['length_settings']['chapters'] target_chaps = bp['length_settings']['chapters']
for d in range(1, depth+1): 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) time.sleep(1)
with open(events_path, "w") as f: json.dump(events, f, indent=2) 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") utils.log("TIMING", f"Structure & Expansion: {time.time() - t_step:.1f}s")
# 4. Chapter Plan # 4. Chapter Plan
chapters_path = os.path.join(folder, "chapters.json") chapters_path = os.path.join(folder, "chapters.json")
t_step = time.time() t_step = time.time()
@@ -72,20 +79,20 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
utils.log("RESUME", "Loading existing chapter plan...") utils.log("RESUME", "Loading existing chapter plan...")
chapters = utils.load_json(chapters_path) chapters = utils.load_json(chapters_path)
else: 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) 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") utils.log("TIMING", f"Chapter Planning: {time.time() - t_step:.1f}s")
# 5. Writing Loop # 5. Writing Loop
ms_path = os.path.join(folder, "manuscript.json") ms_path = os.path.join(folder, "manuscript.json")
loaded_ms = utils.load_json(ms_path) if (resume and os.path.exists(ms_path)) else [] loaded_ms = utils.load_json(ms_path) if (resume and os.path.exists(ms_path)) else []
ms = loaded_ms if loaded_ms is not None else [] ms = loaded_ms if loaded_ms is not None else []
# Load Tracking # Load Tracking
events_track_path = os.path.join(folder, "tracking_events.json") events_track_path = os.path.join(folder, "tracking_events.json")
chars_track_path = os.path.join(folder, "tracking_characters.json") chars_track_path = os.path.join(folder, "tracking_characters.json")
warn_track_path = os.path.join(folder, "tracking_warnings.json") warn_track_path = os.path.join(folder, "tracking_warnings.json")
tracking = {"events": [], "characters": {}, "content_warnings": []} tracking = {"events": [], "characters": {}, "content_warnings": []}
if resume: if resume:
if os.path.exists(events_track_path): if os.path.exists(events_track_path):
@@ -94,15 +101,14 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
tracking['characters'] = utils.load_json(chars_track_path) tracking['characters'] = utils.load_json(chars_track_path)
if os.path.exists(warn_track_path): if os.path.exists(warn_track_path):
tracking['content_warnings'] = utils.load_json(warn_track_path) tracking['content_warnings'] = utils.load_json(warn_track_path)
summary = "The story begins." summary = "The story begins."
if ms: 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...") utils.log("RESUME", f"Rebuilding story context from {len(ms)} existing chapters...")
try: try:
selected = ms[:1] + ms[-4:] if len(ms) > 5 else ms 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]) 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 ROLE: Series Historian
TASK: Create a cumulative 'Story So Far' summary. TASK: Create a cumulative 'Story So Far' summary.
INPUT_TEXT: 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. INSTRUCTIONS: Use dense, factual bullet points. Focus on character meetings, relationships, and known information.
OUTPUT: Summary text. 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 summary = resp_sum.text
except: summary = "The story continues." except: summary = "The story continues."
@@ -122,39 +128,36 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
while i < len(chapters): while i < len(chapters):
ch_start = time.time() ch_start = time.time()
ch = chapters[i] ch = chapters[i]
# Check for stop signal from Web UI # Check for stop signal from Web UI
run_dir = os.path.dirname(folder) run_dir = os.path.dirname(folder)
if os.path.exists(os.path.join(run_dir, ".stop")): 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 break
# Robust Resume: Check if this specific chapter number is already in the manuscript # 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): if any(str(c.get('num')) == str(ch['chapter_number']) for c in ms):
i += 1 i += 1
continue continue
# Progress Banner — update bar and log chapter header before writing begins # Progress Banner
utils.update_progress(15 + int((i / len(chapters)) * 75)) utils.update_progress(15 + int((i / len(chapters)) * 75))
utils.log_banner("WRITER", f"Chapter {ch['chapter_number']}/{len(chapters)}: {ch['title']}") 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 prev_content = ms[-1]['content'] if ms else None
while True: while True:
try: 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 summary_ctx = summary[-8000:] if len(summary) > 8000 else summary
next_hint = chapters[i+1]['title'] if i + 1 < len(chapters) else "" 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: except Exception as e:
utils.log("SYSTEM", f"Chapter generation failed: {e}") utils.log("SYSTEM", f"Chapter generation failed: {e}")
if interactive: if interactive:
if Confirm.ask("Generation failed (quality/error). Retry?", default=True): if Confirm.ask("Generation failed (quality/error). Retry?", default=True):
continue continue
raise e raise e
if interactive: if interactive:
print(f"\n--- Chapter {ch['chapter_number']} Preview ---\n{txt[:800]}...\n-------------------------------") print(f"\n--- Chapter {ch['chapter_number']} Preview ---\n{txt[:800]}...\n-------------------------------")
if Confirm.ask(f"Accept Chapter {ch['chapter_number']}?", default=True): if Confirm.ask(f"Accept Chapter {ch['chapter_number']}?", default=True):
@@ -163,65 +166,64 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
utils.log("SYSTEM", "Regenerating chapter...") utils.log("SYSTEM", "Regenerating chapter...")
else: else:
break 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: 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) 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 = "" next_info = ""
if i + 1 < len(chapters): if i + 1 < len(chapters):
next_ch = chapters[i+1] next_ch = chapters[i+1]
next_info = f"\nUPCOMING CONTEXT (Prioritize details relevant to this): {next_ch.get('title')} - {json.dumps(next_ch.get('beats', []))}" next_info = f"\nUPCOMING CONTEXT (Prioritize details relevant to this): {next_ch.get('title')} - {json.dumps(next_ch.get('beats', []))}"
try: try:
update_prompt = f""" update_prompt = f"""
ROLE: Series Historian ROLE: Series Historian
TASK: Update the 'Story So Far' summary to include the events of this new chapter. TASK: Update the 'Story So Far' summary to include the events of this new chapter.
INPUT_DATA: INPUT_DATA:
- CURRENT_SUMMARY: - CURRENT_SUMMARY:
{summary} {summary}
- NEW_CHAPTER_TEXT: - NEW_CHAPTER_TEXT:
{txt} {txt}
- UPCOMING_CONTEXT_HINT: {next_info} - UPCOMING_CONTEXT_HINT: {next_info}
INSTRUCTIONS: INSTRUCTIONS:
1. STYLE: Dense, factual, chronological bullet points. Avoid narrative prose. 1. STYLE: Dense, factual, chronological bullet points. Avoid narrative prose.
2. CUMULATIVE: Do NOT remove old events. Append and integrate new information. 2. CUMULATIVE: Do NOT remove old events. Append and integrate new information.
3. TRACKING: Explicitly note who met whom, who knows what, and current locations. 3. TRACKING: Explicitly note who met whom, who knows what, and current locations.
4. RELEVANCE: Ensure details needed for the UPCOMING CONTEXT are preserved. 4. RELEVANCE: Ensure details needed for the UPCOMING CONTEXT are preserved.
OUTPUT: Updated summary text. OUTPUT: Updated summary text.
""" """
resp_sum = ai.model_writer.generate_content(update_prompt) resp_sum = ai_models.model_writer.generate_content(update_prompt)
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 summary = resp_sum.text
except: except:
try: try:
resp_fallback = ai.model_writer.generate_content(f"ROLE: Summarizer\nTASK: Summarize plot points.\nTEXT: {txt}\nOUTPUT: Bullet points.") 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.model_writer.name, resp_fallback.usage_metadata) utils.log_usage(folder, ai_models.model_writer.name, resp_fallback.usage_metadata)
summary += f"\n\nChapter {ch['chapter_number']}: " + resp_fallback.text summary += f"\n\nChapter {ch['chapter_number']}: " + resp_fallback.text
except: summary += f"\n\nChapter {ch['chapter_number']}: [Content processed]" except: summary += f"\n\nChapter {ch['chapter_number']}: [Content processed]"
ms.append({'num': ch['chapter_number'], 'title': ch['title'], 'pov_character': ch.get('pov_character'), 'content': txt}) ms.append({'num': ch['chapter_number'], 'title': ch['title'], 'pov_character': ch.get('pov_character'), 'content': txt})
with open(ms_path, "w") as f: json.dump(ms, f, indent=2) with open(ms_path, "w") as f: json.dump(ms, f, indent=2)
# Update Tracking # 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(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(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) 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:] remaining = chapters[i+1:]
if remaining and len(remaining) >= 2 and i % 2 == 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': if pacing and pacing.get('status') == 'add_bridge':
new_data = pacing.get('new_chapter', {}) new_data = pacing.get('new_chapter', {})
# Estimate bridge chapter length from current plan average (not hardcoded)
if chapters: if chapters:
avg_words = int(sum(c.get('estimated_words', 1500) for c in chapters) / len(chapters)) avg_words = int(sum(c.get('estimated_words', 1500) for c in chapters) / len(chapters))
else: else:
@@ -235,19 +237,15 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
"beats": new_data.get('beats', []) "beats": new_data.get('beats', [])
} }
chapters.insert(i+1, new_ch) chapters.insert(i+1, new_ch)
# Renumber subsequent chapters
for k in range(i+1, len(chapters)): chapters[k]['chapter_number'] = k + 1 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) 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': elif pacing and pacing.get('status') == 'cut_next':
removed = chapters.pop(i+1) removed = chapters.pop(i+1)
# Renumber subsequent chapters
for k in range(i+1, len(chapters)): chapters[k]['chapter_number'] = k + 1 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) 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: elif pacing:
utils.log("ARCHITECT", f" -> Pacing OK. {pacing.get('reason', '')[:100]}") utils.log("ARCHITECT", f" -> Pacing OK. {pacing.get('reason', '')[:100]}")
@@ -259,66 +257,60 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
session_time += duration session_time += duration
avg_time = session_time / session_chapters avg_time = session_time / session_chapters
eta = avg_time * (len(chapters) - (i + 1)) eta = avg_time * (len(chapters) - (i + 1))
# Calculate Progress (15% to 90%)
prog = 15 + int((i / len(chapters)) * 75) prog = 15 + int((i / len(chapters)) * 75)
utils.update_progress(prog) utils.update_progress(prog)
word_count = len(txt.split()) if txt else 0 word_count = len(txt.split()) if txt else 0
utils.log("TIMING", f" -> Ch {ch['chapter_number']} done in {duration:.1f}s | {word_count:,} words | Avg: {avg_time:.1f}s | ETA: {int(eta//60)}m {int(eta%60)}s") utils.log("TIMING", f" -> Ch {ch['chapter_number']} done in {duration:.1f}s | {word_count:,} words | Avg: {avg_time:.1f}s | ETA: {int(eta//60)}m {int(eta%60)}s")
utils.log("TIMING", f"Writing Phase: {time.time() - t_step:.1f}s") utils.log("TIMING", f"Writing Phase: {time.time() - t_step:.1f}s")
# Harvest # Harvest
t_step = time.time() t_step = time.time()
utils.update_progress(92) 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) with open(os.path.join(folder, "final_blueprint.json"), "w") as f: json.dump(bp, f, indent=2)
# Create Assets # Create Assets
utils.update_progress(95) 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 # Update Persona
story.update_persona_sample(bp, folder) style_persona.update_persona_sample(bp, folder)
utils.update_progress(98) 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("TIMING", f"Post-Processing: {time.time() - t_step:.1f}s")
utils.log("SYSTEM", f"Book Finished. Total Time: {time.time() - total_start:.1f}s") utils.log("SYSTEM", f"Book Finished. Total Time: {time.time() - total_start:.1f}s")
finally: finally:
# Remove lock file on success or failure
if os.path.exists(lock_path): os.remove(lock_path) if os.path.exists(lock_path): os.remove(lock_path)
# --- 6. ENTRY POINT ---
def run_generation(target=None, specific_run_id=None, interactive=False): 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 if not target: target = config.DEFAULT_BLUEPRINT
data = utils.load_json(target) data = utils.load_json(target)
if not data: if not data:
utils.log("SYSTEM", f"Could not load {target}") utils.log("SYSTEM", f"Could not load {target}")
return return
# --- BIBLE FORMAT ---
utils.log("SYSTEM", "Starting Series Generation...") utils.log("SYSTEM", "Starting Series Generation...")
# Determine Run Directory: projects/{Project}/runs/run_X
project_dir = os.path.dirname(os.path.abspath(target)) project_dir = os.path.dirname(os.path.abspath(target))
runs_base = os.path.join(project_dir, "runs") runs_base = os.path.join(project_dir, "runs")
run_dir = None run_dir = None
resume_mode = False resume_mode = False
if specific_run_id: if specific_run_id:
# WEB/WORKER MODE: Non-interactive, specific ID
run_dir = os.path.join(runs_base, f"run_{specific_run_id}") run_dir = os.path.join(runs_base, f"run_{specific_run_id}")
if not os.path.exists(run_dir): os.makedirs(run_dir) 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: else:
# CLI MODE: Interactive checks
latest_run = utils.get_latest_run_folder(runs_base) latest_run = utils.get_latest_run_folder(runs_base)
if latest_run: if latest_run:
has_lock = False has_lock = False
@@ -326,7 +318,7 @@ def run_generation(target=None, specific_run_id=None, interactive=False):
if ".in_progress" in files: if ".in_progress" in files:
has_lock = True has_lock = True
break break
if has_lock: if has_lock:
if Confirm.ask(f"Found incomplete run '{os.path.basename(latest_run)}'. Resume generation?", default=True): if Confirm.ask(f"Found incomplete run '{os.path.basename(latest_run)}'. Resume generation?", default=True):
run_dir = latest_run run_dir = latest_run
@@ -335,21 +327,19 @@ def run_generation(target=None, specific_run_id=None, interactive=False):
shutil.rmtree(latest_run) shutil.rmtree(latest_run)
os.makedirs(latest_run) os.makedirs(latest_run)
run_dir = latest_run run_dir = latest_run
if not run_dir: run_dir = utils.get_run_folder(runs_base) if not run_dir: run_dir = utils.get_run_folder(runs_base)
utils.log("SYSTEM", f"Run Directory: {run_dir}") utils.log("SYSTEM", f"Run Directory: {run_dir}")
previous_context = "" previous_context = ""
for i, book in enumerate(data['books']): for i, book in enumerate(data['books']):
utils.log("SERIES", f"Processing Book {book.get('book_number')}: {book.get('title')}") 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")): 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 break
# Adapter: Bible -> Blueprint
meta = data['project_metadata'] meta = data['project_metadata']
bp = { bp = {
"book_metadata": { "book_metadata": {
@@ -373,35 +363,27 @@ def run_generation(target=None, specific_run_id=None, interactive=False):
"total_books": len(data['books']) "total_books": len(data['books'])
} }
} }
# Create Book Subfolder
safe_title = utils.sanitize_filename(book.get('title', f"Book_{i+1}")) 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}") book_folder = os.path.join(run_dir, f"Book_{book.get('book_number', i+1)}_{safe_title}")
os.makedirs(book_folder, exist_ok=True) os.makedirs(book_folder, exist_ok=True)
# Process
process_book(bp, book_folder, context=previous_context, resume=resume_mode, interactive=interactive) 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") final_bp_path = os.path.join(book_folder, "final_blueprint.json")
if os.path.exists(final_bp_path): if os.path.exists(final_bp_path):
final_bp = utils.load_json(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', []) new_chars = final_bp.get('characters', [])
# RELOAD BIBLE to avoid race conditions (User might have edited it in UI)
if os.path.exists(target): if os.path.exists(target):
current_bible = utils.load_json(target) current_bible = utils.load_json(target)
# 1. Merge New Characters
existing_names = {c['name'].lower() for c in current_bible.get('characters', [])} existing_names = {c['name'].lower() for c in current_bible.get('characters', [])}
for char in new_chars: for char in new_chars:
if char['name'].lower() not in existing_names: if char['name'].lower() not in existing_names:
current_bible['characters'].append(char) current_bible['characters'].append(char)
# 2. Sync Generated Book Metadata (Title, Beats) back to Bible
for b in current_bible.get('books', []): for b in current_bible.get('books', []):
if b.get('book_number') == book.get('book_number'): if b.get('book_number') == book.get('book_number'):
b['title'] = final_bp['book_metadata'].get('title', b.get('title')) b['title'] = final_bp['book_metadata'].get('title', b.get('title'))
@@ -414,9 +396,10 @@ def run_generation(target=None, specific_run_id=None, interactive=False):
last_beat = final_bp.get('plot_beats', [])[-1] if final_bp.get('plot_beats') else "End of book." last_beat = final_bp.get('plot_beats', [])[-1] if final_bp.get('plot_beats') else "End of book."
previous_context = f"PREVIOUS BOOK SUMMARY: {last_beat}\nCHARACTERS: {json.dumps(final_bp.get('characters', []))}" previous_context = f"PREVIOUS BOOK SUMMARY: {last_beat}\nCHARACTERS: {json.dumps(final_bp.get('characters', []))}"
return return
if __name__ == "__main__": if __name__ == "__main__":
target_arg = sys.argv[1] if len(sys.argv) > 1 else None target_arg = sys.argv[1] if len(sys.argv) > 1 else None
run_generation(target_arg, interactive=True) run_generation(target_arg, interactive=True)

View File

@@ -1,21 +1,24 @@
import os import os
import sys import sys
import json import json
import config
import google.generativeai as genai
from flask import Flask from flask import Flask
from rich.console import Console from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
from rich.prompt import Prompt, IntPrompt, Confirm from rich.prompt import Prompt, IntPrompt, Confirm
from rich.table import Table from rich.table import Table
from modules import ai, utils from core import config, utils
from modules.web_db import db, User, Project 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() console = Console()
try: try:
ai.init_models() ai_setup.init_models()
except Exception as e: 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]") console.print(f"[red]Error: {e}[/red]")
Prompt.ask("Press Enter to exit...") Prompt.ask("Press Enter to exit...")
sys.exit(1) 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.PROJECTS_DIR): os.makedirs(config.PROJECTS_DIR)
if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR) if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR)
class BookWizard: class BookWizard:
def __init__(self): def __init__(self):
self.project_name = "New_Project" self.project_name = "New_Project"
@@ -43,21 +47,20 @@ class BookWizard:
utils.create_default_personas() utils.create_default_personas()
def _get_or_create_wizard_user(self): 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() wizard_user = User.query.filter_by(username="wizard").first()
if not wizard_user: if not wizard_user:
console.print("[yellow]Creating default 'wizard' user for CLI operations...[/yellow]") 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.add(wizard_user)
db.session.commit() db.session.commit()
return wizard_user return wizard_user
def clear(self): os.system('cls' if os.name == 'nt' else 'clear') def clear(self): os.system('cls' if os.name == 'nt' else 'clear')
def ask_gemini_json(self, prompt): def ask_gemini_json(self, prompt):
text = None text = None
try: 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) text = utils.clean_json(response.text)
return json.loads(text) return json.loads(text)
except Exception as e: except Exception as e:
@@ -67,7 +70,7 @@ class BookWizard:
def ask_gemini_text(self, prompt): def ask_gemini_text(self, prompt):
try: try:
response = ai.model_logic.generate_content(prompt) response = ai_models.model_logic.generate_content(prompt)
return response.text.strip() return response.text.strip()
except Exception as e: except Exception as e:
console.print(f"[red]AI Error: {e}[/red]") console.print(f"[red]AI Error: {e}[/red]")
@@ -81,40 +84,38 @@ class BookWizard:
try: try:
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f) with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
except: pass 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()) options = list(personas.keys())
for i, name in enumerate(options): for i, name in enumerate(options):
console.print(f"[{i+1}] {name}") console.print(f"[{i+1}] {name}")
console.print(f"[{len(options)+1}] Create New Persona") console.print(f"[{len(options)+1}] Create New Persona")
console.print(f"[{len(options)+2}] Back") console.print(f"[{len(options)+2}] Back")
console.print(f"[{len(options)+3}] Exit") console.print(f"[{len(options)+3}] Exit")
choice = int(Prompt.ask("Select Option", choices=[str(i) for i in range(1, len(options)+4)])) choice = int(Prompt.ask("Select Option", choices=[str(i) for i in range(1, len(options)+4)]))
if choice == len(options) + 2: break if choice == len(options) + 2: break
elif choice == len(options) + 3: sys.exit() elif choice == len(options) + 3: sys.exit()
selected_key = None selected_key = None
details = {} details = {}
if choice == len(options) + 1: if choice == len(options) + 1:
# Create
console.print("[yellow]Define New Persona[/yellow]") console.print("[yellow]Define New Persona[/yellow]")
selected_key = Prompt.ask("Persona Label (e.g. 'Gritty Detective')", default="New Persona") selected_key = Prompt.ask("Persona Label (e.g. 'Gritty Detective')", default="New Persona")
else: else:
# Edit/Delete Menu for specific persona
selected_key = options[choice-1] selected_key = options[choice-1]
details = personas[selected_key] details = personas[selected_key]
if isinstance(details, str): details = {"bio": details} if isinstance(details, str): details = {"bio": details}
console.print(f"\n[bold]Selected: {selected_key}[/bold]") console.print(f"\n[bold]Selected: {selected_key}[/bold]")
console.print("1. Edit") console.print("1. Edit")
console.print("2. Delete") console.print("2. Delete")
console.print("3. Cancel") console.print("3. Cancel")
sub = int(Prompt.ask("Action", choices=["1", "2", "3"], default="1")) sub = int(Prompt.ask("Action", choices=["1", "2", "3"], default="1"))
if sub == 2: if sub == 2:
if Confirm.ask(f"Delete '{selected_key}'?", default=False): if Confirm.ask(f"Delete '{selected_key}'?", default=False):
@@ -123,8 +124,7 @@ class BookWizard:
continue continue
elif sub == 3: elif sub == 3:
continue continue
# Edit Fields
details['name'] = Prompt.ask("Author Name/Pseudonym", default=details.get('name', "AI Author")) details['name'] = Prompt.ask("Author Name/Pseudonym", default=details.get('name', "AI Author"))
details['age'] = Prompt.ask("Age", default=details.get('age', "Unknown")) details['age'] = Prompt.ask("Age", default=details.get('age', "Unknown"))
details['gender'] = Prompt.ask("Gender", default=details.get('gender', "Unknown")) details['gender'] = Prompt.ask("Gender", default=details.get('gender', "Unknown"))
@@ -132,16 +132,15 @@ class BookWizard:
details['nationality'] = Prompt.ask("Nationality/Country", default=details.get('nationality', "Unknown")) details['nationality'] = Prompt.ask("Nationality/Country", default=details.get('nationality', "Unknown"))
details['language'] = Prompt.ask("Primary Language/Dialect", default=details.get('language', "Standard English")) details['language'] = Prompt.ask("Primary Language/Dialect", default=details.get('language', "Standard English"))
details['bio'] = Prompt.ask("Writing Style/Bio", default=details.get('bio', "")) details['bio'] = Prompt.ask("Writing Style/Bio", default=details.get('bio', ""))
# Samples
console.print("\n[bold]Style Samples[/bold]") console.print("\n[bold]Style Samples[/bold]")
console.print(f"Place text files in the '{config.PERSONAS_DIR}' folder to reference them.") console.print(f"Place text files in the '{config.PERSONAS_DIR}' folder to reference them.")
curr_files = details.get('sample_files', []) curr_files = details.get('sample_files', [])
files_str = ",".join(curr_files) files_str = ",".join(curr_files)
new_files = Prompt.ask("Sample Text Files (comma sep filenames)", default=files_str) new_files = Prompt.ask("Sample Text Files (comma sep filenames)", default=files_str)
details['sample_files'] = [x.strip() for x in new_files.split(',') if x.strip()] details['sample_files'] = [x.strip() for x in new_files.split(',') if x.strip()]
details['sample_text'] = Prompt.ask("Manual Sample Paragraph", default=details.get('sample_text', "")) details['sample_text'] = Prompt.ask("Manual Sample Paragraph", default=details.get('sample_text', ""))
if Confirm.ask("Save Persona?", default=True): if Confirm.ask("Save Persona?", default=True):
@@ -151,14 +150,14 @@ class BookWizard:
def select_mode(self): def select_mode(self):
while True: while True:
self.clear() 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("1. Create New Project")
console.print("2. Open Existing Project") console.print("2. Open Existing Project")
console.print("3. Manage Author Personas") console.print("3. Manage Author Personas")
console.print("4. Exit") console.print("4. Exit")
choice = int(Prompt.ask("Select Mode", choices=["1", "2", "3", "4"], default="1")) choice = int(Prompt.ask("Select Mode", choices=["1", "2", "3", "4"], default="1"))
if choice == 1: if choice == 1:
self.user = self._get_or_create_wizard_user() self.user = self._get_or_create_wizard_user()
return self.create_new_project() return self.create_new_project()
@@ -166,28 +165,26 @@ class BookWizard:
self.user = self._get_or_create_wizard_user() self.user = self._get_or_create_wizard_user()
if self.open_existing_project(): return True if self.open_existing_project(): return True
elif choice == 3: elif choice == 3:
# Personas don't need a user context
self.manage_personas() self.manage_personas()
else: else:
return False return False
def create_new_project(self): def create_new_project(self):
self.clear() 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).") console.print("Tell me about your story idea (or leave empty to start from scratch).")
concept = Prompt.ask("Story Concept") concept = Prompt.ask("Story Concept")
suggestions = {} suggestions = {}
if concept: if concept:
with console.status("[bold yellow]AI is analyzing your concept...[/bold yellow]"): with console.status("[bold yellow]AI is analyzing your concept...[/bold yellow]"):
prompt = f""" prompt = f"""
ROLE: Publishing Analyst ROLE: Publishing Analyst
TASK: Suggest metadata for a story concept. TASK: Suggest metadata for a story concept.
CONCEPT: {concept} CONCEPT: {concept}
OUTPUT_FORMAT (JSON): OUTPUT_FORMAT (JSON):
{{ {{
"title": "String", "title": "String",
@@ -217,12 +214,12 @@ class BookWizard:
while True: while True:
self.clear() 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 = Table.grid(padding=(0, 2))
grid.add_column(style="bold cyan") grid.add_column(style="bold cyan")
grid.add_column() grid.add_column()
def get_str(k): def get_str(k):
v = suggestions.get(k, 'N/A') v = suggestions.get(k, 'N/A')
if isinstance(v, list): return ", ".join(v) if isinstance(v, list): return ", ".join(v)
@@ -232,28 +229,27 @@ class BookWizard:
grid.add_row("Genre:", get_str('genre')) grid.add_row("Genre:", get_str('genre'))
grid.add_row("Audience:", get_str('target_audience')) grid.add_row("Audience:", get_str('target_audience'))
grid.add_row("Tone:", get_str('tone')) grid.add_row("Tone:", get_str('tone'))
len_cat = suggestions.get('length_category', '4') len_cat = suggestions.get('length_category', '4')
len_label = config.LENGTH_DEFINITIONS.get(len_cat, {}).get('label', 'Novel') len_label = config.LENGTH_DEFINITIONS.get(len_cat, {}).get('label', 'Novel')
grid.add_row("Length:", len_label) grid.add_row("Length:", len_label)
grid.add_row("Est. Chapters:", str(suggestions.get('estimated_chapters', 'N/A'))) 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("Est. Words:", str(suggestions.get('estimated_word_count', 'N/A')))
grid.add_row("Tropes:", get_str('tropes')) grid.add_row("Tropes:", get_str('tropes'))
grid.add_row("POV:", get_str('pov_style')) grid.add_row("POV:", get_str('pov_style'))
grid.add_row("Time:", get_str('time_period')) grid.add_row("Time:", get_str('time_period'))
grid.add_row("Spice:", get_str('spice')) grid.add_row("Spice:", get_str('spice'))
grid.add_row("Violence:", get_str('violence')) grid.add_row("Violence:", get_str('violence'))
grid.add_row("Series:", "Yes" if suggestions.get('is_series') else "No") grid.add_row("Series:", "Yes" if suggestions.get('is_series') else "No")
console.print(grid) console.print(grid)
console.print("\n[dim]These will be the defaults for the next step.[/dim]") console.print("\n[dim]These will be the defaults for the next step.[/dim]")
console.print("\n1. Continue (Manual Step-through)") console.print("\n1. Continue (Manual Step-through)")
console.print("2. Refine Suggestions with AI") console.print("2. Refine Suggestions with AI")
choice = Prompt.ask("Select Option", choices=["1", "2"], default="1") choice = Prompt.ask("Select Option", choices=["1", "2"], default="1")
if choice == "1": if choice == "1":
break break
else: else:
@@ -262,25 +258,24 @@ class BookWizard:
refine_prompt = f""" refine_prompt = f"""
ROLE: Publishing Analyst ROLE: Publishing Analyst
TASK: Refine project metadata based on user instruction. TASK: Refine project metadata based on user instruction.
INPUT_DATA: INPUT_DATA:
- CURRENT_JSON: {json.dumps(suggestions)} - CURRENT_JSON: {json.dumps(suggestions)}
- INSTRUCTION: {instruction} - INSTRUCTION: {instruction}
OUTPUT_FORMAT (JSON): Same structure as input. Ensure length_category matches word count. OUTPUT_FORMAT (JSON): Same structure as input. Ensure length_category matches word count.
""" """
new_sugg = self.ask_gemini_json(refine_prompt) new_sugg = self.ask_gemini_json(refine_prompt)
if new_sugg: suggestions = new_sugg if new_sugg: suggestions = new_sugg
# 2. Select Type (with AI default)
default_type = "2" if suggestions.get('is_series') else "1" default_type = "2" if suggestions.get('is_series') else "1"
console.print("1. Standalone Book") console.print("1. Standalone Book")
console.print("2. Series") console.print("2. Series")
choice = int(Prompt.ask("Select Type", choices=["1", "2"], default=default_type)) choice = int(Prompt.ask("Select Type", choices=["1", "2"], default=default_type))
is_series = (choice == 2) is_series = (choice == 2)
self.configure_details(suggestions, concept, is_series) self.configure_details(suggestions, concept, is_series)
self.enrich_blueprint() self.enrich_blueprint()
self.refine_blueprint("Review & Edit Bible") self.refine_blueprint("Review & Edit Bible")
@@ -288,24 +283,23 @@ class BookWizard:
return True return True
def open_existing_project(self): 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() projects = Project.query.filter_by(user_id=self.user.id).order_by(Project.name).all()
if not projects: if not projects:
console.print(f"[red]No projects found for user '{self.user.username}'. Create one first.[/red]") console.print(f"[red]No projects found for user '{self.user.username}'. Create one first.[/red]")
Prompt.ask("Press Enter to continue...") Prompt.ask("Press Enter to continue...")
return False 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): for i, p in enumerate(projects):
console.print(f"[{i+1}] {p.name}") console.print(f"[{i+1}] {p.name}")
console.print(f"[{len(projects)+1}] Back") console.print(f"[{len(projects)+1}] Back")
console.print(f"[{len(projects)+2}] Exit") console.print(f"[{len(projects)+2}] Exit")
choice = int(Prompt.ask("Select Project", choices=[str(i) for i in range(1, len(projects)+3)])) choice = int(Prompt.ask("Select Project", choices=[str(i) for i in range(1, len(projects)+3)]))
if choice == len(projects) + 1: return False if choice == len(projects) + 1: return False
if choice == len(projects) + 2: sys.exit() if choice == len(projects) + 2: sys.exit()
selected_project = projects[choice-1] selected_project = projects[choice-1]
self.project_name = selected_project.name self.project_name = selected_project.name
self.project_path = selected_project.folder_path self.project_path = selected_project.folder_path
@@ -315,7 +309,7 @@ class BookWizard:
if not self.project_path: if not self.project_path:
console.print("[red]No project loaded.[/red]") console.print("[red]No project loaded.[/red]")
return False return False
path = os.path.join(self.project_path, "bible.json") path = os.path.join(self.project_path, "bible.json")
if os.path.exists(path): if os.path.exists(path):
with open(path, 'r') as f: self.data = json.load(f) with open(path, 'r') as f: self.data = json.load(f)
@@ -325,47 +319,44 @@ class BookWizard:
def configure_details(self, suggestions=None, concept="", is_series=False): def configure_details(self, suggestions=None, concept="", is_series=False):
if suggestions is None: suggestions = {} 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 = {} personas = {}
if os.path.exists(config.PERSONAS_FILE): if os.path.exists(config.PERSONAS_FILE):
try: try:
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f) with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
except: pass except: pass
author_details = {} author_details = {}
if personas: if personas:
console.print("\n[bold]Select Author Persona[/bold]") console.print("\n[bold]Select Author Persona[/bold]")
opts = list(personas.keys()) opts = list(personas.keys())
for i, p in enumerate(opts): console.print(f"[{i+1}] {p}") for i, p in enumerate(opts): console.print(f"[{i+1}] {p}")
console.print(f"[{len(opts)+1}] None (AI Default)") console.print(f"[{len(opts)+1}] None (AI Default)")
sel = IntPrompt.ask("Option", choices=[str(i) for i in range(1, len(opts)+2)], default=len(opts)+1) sel = IntPrompt.ask("Option", choices=[str(i) for i in range(1, len(opts)+2)], default=len(opts)+1)
if sel <= len(opts): if sel <= len(opts):
author_details = personas[opts[sel-1]] author_details = personas[opts[sel-1]]
if isinstance(author_details, str): author_details = {"bio": author_details} if isinstance(author_details, str): author_details = {"bio": author_details}
default_author = author_details.get('name', "AI Co-Pilot") default_author = author_details.get('name', "AI Co-Pilot")
author = Prompt.ask("Author Name", default=default_author) author = Prompt.ask("Author Name", default=default_author)
genre = Prompt.ask("Genre", default=suggestions.get('genre', "Fiction")) genre = Prompt.ask("Genre", default=suggestions.get('genre', "Fiction"))
# LENGTH SELECTION # LENGTH SELECTION
table = Table(title="Target Length Options") table = Table(title="Target Length Options")
table.add_column("#"); table.add_column("Type"); table.add_column("Est. Words"); table.add_column("Chapters") table.add_column("#"); table.add_column("Type"); table.add_column("Est. Words"); table.add_column("Chapters")
for k, v in config.LENGTH_DEFINITIONS.items(): for k, v in config.LENGTH_DEFINITIONS.items():
table.add_row(k, v['label'], v['words'], str(v['chapters'])) table.add_row(k, v['label'], v['words'], str(v['chapters']))
console.print(table) console.print(table)
def_len = suggestions.get('length_category', "4") def_len = suggestions.get('length_category', "4")
if def_len not in config.LENGTH_DEFINITIONS: def_len = "4" 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) 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() settings = config.LENGTH_DEFINITIONS[len_choice].copy()
# AI Defaults
def_chapters = suggestions.get('estimated_chapters', settings['chapters']) def_chapters = suggestions.get('estimated_chapters', settings['chapters'])
def_words = suggestions.get('estimated_word_count', settings['words']) def_words = suggestions.get('estimated_word_count', settings['words'])
def_prologue = suggestions.get('include_prologue', False) def_prologue = suggestions.get('include_prologue', False)
@@ -375,9 +366,8 @@ class BookWizard:
settings['words'] = Prompt.ask("Target Word Count", default=str(def_words)) settings['words'] = Prompt.ask("Target Word Count", default=str(def_words))
settings['include_prologue'] = Confirm.ask("Include Prologue?", default=def_prologue) settings['include_prologue'] = Confirm.ask("Include Prologue?", default=def_prologue)
settings['include_epilogue'] = Confirm.ask("Include Epilogue?", default=def_epilogue) settings['include_epilogue'] = Confirm.ask("Include Epilogue?", default=def_epilogue)
# --- GENRE STANDARD CHECK --- # Genre Standard Check
# Parse current word count selection
w_str = str(settings.get('words', '0')).replace(',', '').replace('+', '').lower() w_str = str(settings.get('words', '0')).replace(',', '').replace('+', '').lower()
avg_words = 0 avg_words = 0
if '-' in w_str: if '-' in w_str:
@@ -388,7 +378,6 @@ class BookWizard:
try: avg_words = int(w_str.replace('k', '000')) try: avg_words = int(w_str.replace('k', '000'))
except: pass except: pass
# Define rough standards
std_target = 0 std_target = 0
g_lower = genre.lower() 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 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 elif "young adult" in g_lower or "ya" in g_lower: std_target = 60000
if std_target > 0 and avg_words > 0: if std_target > 0 and avg_words > 0:
# If difference is > 25%, warn user
if abs(std_target - avg_words) / std_target > 0.25: 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): if Confirm.ask(f"Update target to {std_target:,} words?", default=True):
settings['words'] = f"{std_target:,}" settings['words'] = f"{std_target:,}"
@@ -408,19 +396,15 @@ class BookWizard:
def_tropes = ", ".join(suggestions.get('tropes', [])) def_tropes = ", ".join(suggestions.get('tropes', []))
tropes_input = Prompt.ask("Tropes/Themes (comma sep)", default=def_tropes) 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 [] 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', "")) 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" default_proj = utils.sanitize_filename(title) if title else "New_Project"
self.project_name = Prompt.ask("Project Name (Folder)", default=default_proj) 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)) user_dir = os.path.join(config.DATA_DIR, "users", str(self.user.id))
if not os.path.exists(user_dir): os.makedirs(user_dir) if not os.path.exists(user_dir): os.makedirs(user_dir)
self.project_path = os.path.join(user_dir, self.project_name) self.project_path = os.path.join(user_dir, self.project_name)
if os.path.exists(self.project_path): if os.path.exists(self.project_path):
console.print(f"[yellow]Warning: Project folder '{self.project_path}' already exists.[/yellow]") console.print(f"[yellow]Warning: Project folder '{self.project_path}' already exists.[/yellow]")
@@ -433,55 +417,50 @@ class BookWizard:
console.print("\n[italic]Note: Tone describes the overall mood or atmosphere (e.g. Dark, Whimsical, Cynical, Hopeful).[/italic]") 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")) 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_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_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 [] 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")) tense = Prompt.ask("Narrative Tense (e.g. 'Past', 'Present')", default=suggestions.get('narrative_tense', "Past"))
console.print("\n[bold]Content Guidelines[/bold]") console.print("\n[bold]Content Guidelines[/bold]")
spice = Prompt.ask("Spice/Romance (e.g. 'Clean', 'Fade-to-Black', 'Explicit')", default=suggestions.get('spice', "Standard")) spice = Prompt.ask("Spice/Romance (e.g. 'Clean', 'Fade-to-Black', 'Explicit')", default=suggestions.get('spice', "Standard"))
violence = Prompt.ask("Violence (e.g. 'None', 'Mild', 'Graphic')", default=suggestions.get('violence', "Standard")) violence = Prompt.ask("Violence (e.g. 'None', 'Mild', 'Graphic')", default=suggestions.get('violence', "Standard"))
language = Prompt.ask("Language (e.g. 'No Swearing', 'Mild', 'Heavy')", default=suggestions.get('language_style', "Standard")) language = Prompt.ask("Language (e.g. 'No Swearing', 'Mild', 'Heavy')", default=suggestions.get('language_style', "Standard"))
dialogue_style = Prompt.ask("Dialogue Style (e.g. 'Witty', 'Formal', 'Slang-heavy')", default=suggestions.get('dialogue_style', "Standard")) dialogue_style = Prompt.ask("Dialogue Style (e.g. 'Witty', 'Formal', 'Slang-heavy')", default=suggestions.get('dialogue_style', "Standard"))
console.print("\n[bold]Formatting & World Rules[/bold]") 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")) 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")) 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]") console.print("[italic]Define formatting rules (e.g. 'Chapter Headers: POV + Title', 'Text Messages: Italic').[/italic]")
def_fmt = ", ".join(suggestions.get('formatting_rules', ["Chapter Headers: Number + Title", "Text Messages: Italic", "Thoughts: Italic"])) def_fmt = ", ".join(suggestions.get('formatting_rules', ["Chapter Headers: Number + Title", "Text Messages: Italic", "Thoughts: Italic"]))
fmt_input = Prompt.ask("Formatting Rules (comma sep)", default=def_fmt) 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 [] fmt_rules = [x.strip() for x in fmt_input.split(',')] if fmt_input else []
# Update book_metadata with new fields
style_data = { style_data = {
"tone": tone, "tropes": sel_tropes, "tone": tone, "tropes": sel_tropes,
"pov_style": pov_style, "pov_characters": pov_chars, "pov_style": pov_style, "pov_characters": pov_chars,
"tense": tense, "spice": spice, "violence": violence, "language": language, "tense": tense, "spice": spice, "violence": violence, "language": language,
"dialogue_style": dialogue_style, "time_period": time_period, "dialogue_style": dialogue_style, "time_period": time_period,
"page_orientation": orientation, "page_orientation": orientation,
"formatting_rules": fmt_rules "formatting_rules": fmt_rules
} }
self.data['project_metadata'] = { self.data['project_metadata'] = {
"title": title, "title": title,
"author": author, "author": author,
"author_details": author_details, "author_details": author_details,
"author_bio": author_details.get('bio', ''), "author_bio": author_details.get('bio', ''),
"genre": genre, "genre": genre,
"target_audience": Prompt.ask("Audience", default=suggestions.get('target_audience', "Adult")), "target_audience": Prompt.ask("Audience", default=suggestions.get('target_audience', "Adult")),
"is_series": is_series, "is_series": is_series,
"length_settings": settings, "length_settings": settings,
"style": style_data "style": style_data
} }
# Initialize Books List
self.data['books'] = [] self.data['books'] = []
if is_series: if is_series:
count = IntPrompt.ask("How many books in the series?", default=3) count = IntPrompt.ask("How many books in the series?", default=3)
@@ -501,20 +480,20 @@ class BookWizard:
}) })
def enrich_blueprint(self): 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""" prompt = f"""
ROLE: Creative Director ROLE: Creative Director
TASK: Create a comprehensive Book Bible. TASK: Create a comprehensive Book Bible.
INPUT_DATA: INPUT_DATA:
- METADATA: {json.dumps(self.data['project_metadata'])} - METADATA: {json.dumps(self.data['project_metadata'])}
- BOOKS: {json.dumps(self.data['books'])} - BOOKS: {json.dumps(self.data['books'])}
INSTRUCTIONS: INSTRUCTIONS:
1. Create Main Characters. 1. Create Main Characters.
2. For EACH book: Generate Title, Plot Summary (manual_instruction), and 10-15 Plot Beats. 2. For EACH book: Generate Title, Plot Summary (manual_instruction), and 10-15 Plot Beats.
OUTPUT_FORMAT (JSON): OUTPUT_FORMAT (JSON):
{{ {{
"characters": [ {{ "name": "String", "role": "String", "description": "String" }} ], "characters": [ {{ "name": "String", "role": "String", "description": "String" }} ],
@@ -527,11 +506,9 @@ class BookWizard:
if new_data: if new_data:
if 'characters' in new_data: if 'characters' in new_data:
self.data['characters'] = new_data['characters'] 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']] 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: if 'books' in new_data:
# Merge book data carefully
ai_books = {b.get('book_number'): b for b in new_data['books']} ai_books = {b.get('book_number'): b for b in new_data['books']}
for i, book in enumerate(self.data['books']): for i, book in enumerate(self.data['books']):
b_num = book.get('book_number', i+1) b_num = book.get('book_number', i+1)
@@ -547,53 +524,40 @@ class BookWizard:
meta = data.get('project_metadata', {}) meta = data.get('project_metadata', {})
length = meta.get('length_settings', {}) length = meta.get('length_settings', {})
style = meta.get('style', {}) style = meta.get('style', {})
# Metadata Grid
grid = Table.grid(padding=(0, 2)) grid = Table.grid(padding=(0, 2))
grid.add_column(style="bold cyan") grid.add_column(style="bold cyan")
grid.add_column() grid.add_column()
grid.add_row("Title:", meta.get('title', 'N/A')) grid.add_row("Title:", meta.get('title', 'N/A'))
grid.add_row("Author:", meta.get('author', 'N/A')) grid.add_row("Author:", meta.get('author', 'N/A'))
grid.add_row("Genre:", meta.get('genre', 'N/A')) grid.add_row("Genre:", meta.get('genre', 'N/A'))
grid.add_row("Audience:", meta.get('target_audience', 'N/A')) grid.add_row("Audience:", meta.get('target_audience', 'N/A'))
# Dynamic Style Display
# Define explicit order for common fields
ordered_keys = [ ordered_keys = [
"tone", "pov_style", "pov_characters", "tone", "pov_style", "pov_characters",
"tense", "spice", "violence", "language", "dialogue_style", "time_period", "page_orientation", "tense", "spice", "violence", "language", "dialogue_style", "time_period", "page_orientation",
"tropes" "tropes"
] ]
defaults = { defaults = {
"tone": "Balanced", "tone": "Balanced", "pov_style": "Third Person Limited", "tense": "Past",
"pov_style": "Third Person Limited", "spice": "Standard", "violence": "Standard", "language": "Standard",
"tense": "Past", "dialogue_style": "Standard", "time_period": "Modern", "page_orientation": "Portrait"
"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: for k in ordered_keys:
val = style.get(k) val = style.get(k)
if val in [None, "", "N/A"]: if val in [None, "", "N/A"]:
val = defaults.get(k, 'N/A') val = defaults.get(k, 'N/A')
if isinstance(val, list): val = ", ".join(val) if isinstance(val, list): val = ", ".join(val)
if isinstance(val, bool): val = "Yes" if val else "No" if isinstance(val, bool): val = "Yes" if val else "No"
grid.add_row(f"{k.replace('_', ' ').title()}:", str(val)) grid.add_row(f"{k.replace('_', ' ').title()}:", str(val))
# 2. Show remaining keys
for k, v in style.items(): for k, v in style.items():
if k not in ordered_keys and k != 'formatting_rules': if k not in ordered_keys and k != 'formatting_rules':
val = ", ".join(v) if isinstance(v, list) else str(v) val = ", ".join(v) if isinstance(v, list) else str(v)
grid.add_row(f"{k.replace('_', ' ').title()}:", val) grid.add_row(f"{k.replace('_', ' ').title()}:", val)
len_str = f"{length.get('label', 'N/A')} ({length.get('words', 'N/A')} words, {length.get('chapters', 'N/A')} ch)" len_str = f"{length.get('label', 'N/A')} ({length.get('words', 'N/A')} words, {length.get('chapters', 'N/A')} ch)"
extras = [] extras = []
if length.get('include_prologue'): extras.append("Prologue") if length.get('include_prologue'): extras.append("Prologue")
@@ -601,32 +565,28 @@ class BookWizard:
if extras: len_str += f" + {', '.join(extras)}" if extras: len_str += f" + {', '.join(extras)}"
grid.add_row("Length:", len_str) grid.add_row("Length:", len_str)
grid.add_row("Series:", "Yes" if meta.get('is_series') else "No") 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', []) fmt_rules = style.get('formatting_rules', [])
if fmt_rules: if fmt_rules:
fmt_table = Table(title="Formatting Rules", show_header=False, box=None, expand=True) fmt_table = Table(title="Formatting Rules", show_header=False, box=None, expand=True)
for i, r in enumerate(fmt_rules): for i, r in enumerate(fmt_rules):
fmt_table.add_row(f"[bold]{i+1}.[/bold]", str(r)) 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("Name", style="green")
char_table.add_column("Role") char_table.add_column("Role")
char_table.add_column("Description") char_table.add_column("Description")
for c in data.get('characters', []): 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', '-')) char_table.add_row(c.get('name', '-'), c.get('role', '-'), c.get('description', '-'))
console.print(char_table) console.print(char_table)
# Books List
for book in data.get('books', []): 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]") console.print(f"[italic]{book.get('manual_instruction')}[/italic]")
beats = book.get('plot_beats', []) beats = book.get('plot_beats', [])
if beats: if beats:
beat_table = Table(show_header=False, box=None, expand=True) beat_table = Table(show_header=False, box=None, expand=True)
@@ -637,41 +597,40 @@ class BookWizard:
def refine_blueprint(self, title="Refine Blueprint"): def refine_blueprint(self, title="Refine Blueprint"):
while True: while True:
self.clear() 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) self.display_summary(self.data)
console.print("\n[dim](Full JSON loaded)[/dim]") 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]") 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 if change.lower() == 'done': break
# Inner loop for refinement
current_data = self.data current_data = self.data
instruction = change instruction = change
while True: while True:
with console.status("[bold green]AI is updating blueprint...[/bold green]"): with console.status("[bold green]AI is updating blueprint...[/bold green]"):
prompt = f""" prompt = f"""
ROLE: Senior Editor ROLE: Senior Editor
TASK: Update the Bible JSON based on instruction. TASK: Update the Bible JSON based on instruction.
INPUT_DATA: INPUT_DATA:
- CURRENT_JSON: {json.dumps(current_data)} - CURRENT_JSON: {json.dumps(current_data)}
- INSTRUCTION: {instruction} - INSTRUCTION: {instruction}
OUTPUT_FORMAT (JSON): The full updated JSON object. OUTPUT_FORMAT (JSON): The full updated JSON object.
""" """
new_data = self.ask_gemini_json(prompt) new_data = self.ask_gemini_json(prompt)
if not new_data: if not new_data:
console.print("[red]AI failed to generate valid JSON.[/red]") console.print("[red]AI failed to generate valid JSON.[/red]")
break break
self.clear() 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) 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]") feedback = Prompt.ask("\n[bold green]Is this good? (Type 'yes' to save, or enter feedback to refine)[/bold green]")
if feedback.lower() == 'yes': if feedback.lower() == 'yes':
self.data = new_data self.data = new_data
console.print("[green]Changes saved![/green]") console.print("[green]Changes saved![/green]")
@@ -687,9 +646,9 @@ class BookWizard:
if not os.path.exists(self.project_path): os.makedirs(self.project_path) if not os.path.exists(self.project_path): os.makedirs(self.project_path)
filename = os.path.join(self.project_path, "bible.json") filename = os.path.join(self.project_path, "bible.json")
with open(filename, 'w') as f: json.dump(self.data, f, indent=2) 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 return filename
def manage_runs(self): def manage_runs(self):
@@ -701,7 +660,7 @@ class BookWizard:
return return
runs = sorted([d for d in os.listdir(runs_dir) if os.path.isdir(os.path.join(runs_dir, d)) and d.startswith("run_")], key=lambda x: int(x.split('_')[1]) if x.split('_')[1].isdigit() else 0, reverse=True) runs = sorted([d for d in os.listdir(runs_dir) if os.path.isdir(os.path.join(runs_dir, d)) and d.startswith("run_")], key=lambda x: int(x.split('_')[1]) if x.split('_')[1].isdigit() else 0, reverse=True)
if not runs: if not runs:
console.print("[red]No runs found.[/red]") console.print("[red]No runs found.[/red]")
Prompt.ask("Press Enter...") Prompt.ask("Press Enter...")
@@ -718,35 +677,33 @@ class BookWizard:
choice = int(Prompt.ask("Select Run", choices=[str(i) for i in range(1, len(runs)+3)])) choice = int(Prompt.ask("Select Run", choices=[str(i) for i in range(1, len(runs)+3)]))
if choice == len(runs) + 1: break if choice == len(runs) + 1: break
elif choice == len(runs) + 2: sys.exit() elif choice == len(runs) + 2: sys.exit()
selected_run = runs[choice-1] selected_run = runs[choice-1]
run_path = os.path.join(runs_dir, selected_run) run_path = os.path.join(runs_dir, selected_run)
self.manage_specific_run(run_path) self.manage_specific_run(run_path)
def manage_specific_run(self, run_path): def manage_specific_run(self, run_path):
while True: while True:
self.clear() self.clear()
console.print(Panel(f"[bold blue]Run: {os.path.basename(run_path)}[/bold blue]")) 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_")]) 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: if subdirs:
console.print("[italic]Series Run Detected[/italic]") console.print("[italic]Series Run Detected[/italic]")
for i, s in enumerate(subdirs): for i, s in enumerate(subdirs):
console.print(f"[{i+1}] Manage {s}") console.print(f"[{i+1}] Manage {s}")
idx_open = len(subdirs) + 1 idx_open = len(subdirs) + 1
idx_back = len(subdirs) + 2 idx_back = len(subdirs) + 2
idx_exit = len(subdirs) + 3 idx_exit = len(subdirs) + 3
console.print(f"[{idx_open}] Open Run Folder") console.print(f"[{idx_open}] Open Run Folder")
console.print(f"[{idx_back}] Back") console.print(f"[{idx_back}] Back")
console.print(f"[{idx_exit}] Exit") console.print(f"[{idx_exit}] Exit")
choice = int(Prompt.ask("Select Option", choices=[str(i) for i in range(1, idx_exit+1)])) choice = int(Prompt.ask("Select Option", choices=[str(i) for i in range(1, idx_exit+1)]))
if choice <= len(subdirs): if choice <= len(subdirs):
book_path = os.path.join(run_path, subdirs[choice-1]) book_path = os.path.join(run_path, subdirs[choice-1])
self.manage_single_book_folder(book_path) self.manage_single_book_folder(book_path)
@@ -764,31 +721,28 @@ class BookWizard:
console.print("1. Regenerate Cover & Recompile EPUB") console.print("1. Regenerate Cover & Recompile EPUB")
console.print("2. Open Folder") console.print("2. Open Folder")
console.print("3. Back") console.print("3. Back")
choice = int(Prompt.ask("Select Action", choices=["1", "2", "3"])) choice = int(Prompt.ask("Select Action", choices=["1", "2", "3"]))
if choice == 1: if choice == 1:
import main
bp_path = os.path.join(folder_path, "final_blueprint.json") bp_path = os.path.join(folder_path, "final_blueprint.json")
ms_path = os.path.join(folder_path, "manuscript.json") ms_path = os.path.join(folder_path, "manuscript.json")
if os.path.exists(bp_path) and os.path.exists(ms_path): if os.path.exists(bp_path) and os.path.exists(ms_path):
with console.status("[bold yellow]Regenerating Cover...[/bold yellow]"): with console.status("[bold yellow]Regenerating Cover...[/bold yellow]"):
with open(bp_path, 'r') as f: bp = json.load(f) with open(bp_path, 'r') as f: bp = json.load(f)
with open(ms_path, 'r') as f: ms = 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") events_path = os.path.join(folder_path, "tracking_events.json")
chars_path = os.path.join(folder_path, "tracking_characters.json") chars_path = os.path.join(folder_path, "tracking_characters.json")
tracking = {"events": [], "characters": {}} tracking = {"events": [], "characters": {}}
if os.path.exists(events_path): tracking['events'] = utils.load_json(events_path) 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) 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']: if not tracking['events'] and not tracking['characters']:
# Fallback: Use Blueprint data
console.print("[yellow]Tracking missing. Populating from Blueprint...[/yellow]") console.print("[yellow]Tracking missing. Populating from Blueprint...[/yellow]")
tracking['events'] = bp.get('plot_beats', []) tracking['events'] = bp.get('plot_beats', [])
tracking['characters'] = {} tracking['characters'] = {}
@@ -801,9 +755,9 @@ class BookWizard:
} }
with open(events_path, 'w') as f: json.dump(tracking['events'], f, indent=2) 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) with open(chars_path, 'w') as f: json.dump(tracking['characters'], f, indent=2)
main.marketing.generate_cover(bp, folder_path, tracking) marketing_cover.generate_cover(bp, folder_path, tracking)
main.export.compile_files(bp, ms, folder_path) exporter.compile_files(bp, ms, folder_path)
console.print("[green]Cover updated and EPUB recompiled![/green]") console.print("[green]Cover updated and EPUB recompiled![/green]")
Prompt.ask("Press Enter...") Prompt.ask("Press Enter...")
else: else:
@@ -820,21 +774,22 @@ class BookWizard:
else: else:
os.system(f"open '{path}'") os.system(f"open '{path}'")
if __name__ == "__main__": if __name__ == "__main__":
w = BookWizard() w = BookWizard()
with app.app_context(): with app.app_context():
try: try:
if w.select_mode(): if w.select_mode():
while True: while True:
w.clear() 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("1. Edit Bible")
console.print("2. Run Book Generation") console.print("2. Run Book Generation")
console.print("3. Manage Runs") console.print("3. Manage Runs")
console.print("4. Exit") console.print("4. Exit")
choice = int(Prompt.ask("Select Option", choices=["1", "2", "3", "4"])) choice = int(Prompt.ask("Select Option", choices=["1", "2", "3", "4"]))
if choice == 1: if choice == 1:
if w.load_bible(): if w.load_bible():
w.refine_blueprint("Refine Bible") w.refine_blueprint("Refine Bible")
@@ -842,14 +797,10 @@ if __name__ == "__main__":
elif choice == 2: elif choice == 2:
if w.load_bible(): if w.load_bible():
bible_path = os.path.join(w.project_path, "bible.json") bible_path = os.path.join(w.project_path, "bible.json")
import main run_generation(bible_path, interactive=True)
main.run_generation(bible_path, interactive=True)
Prompt.ask("\nGeneration complete. Press Enter...") Prompt.ask("\nGeneration complete. Press Enter...")
elif choice == 3: elif choice == 3:
# Manage runs
w.manage_runs() w.manage_runs()
else: else:
break break
else: except KeyboardInterrupt: console.print("\n[red]Cancelled.[/red]")
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 import os
from dotenv import load_dotenv from dotenv import load_dotenv
# Ensure .env is loaded from the script's directory (VS Code fix) # __file__ is core/config.py; app root is one level up
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")) _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): def get_clean_env(key, default=None):
val = os.getenv(key, default) 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.") if not API_KEY: raise ValueError("❌ CRITICAL ERROR: GEMINI_API_KEY not found.")
# --- DATA DIRECTORIES --- # --- DATA DIRECTORIES ---
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data") DATA_DIR = os.path.join(BASE_DIR, "data")
PROJECTS_DIR = os.path.join(DATA_DIR, "projects") PROJECTS_DIR = os.path.join(DATA_DIR, "projects")
PERSONAS_DIR = os.path.join(DATA_DIR, "personas") 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") FONTS_DIR = os.path.join(DATA_DIR, "fonts")
# --- ENSURE DIRECTORIES EXIST --- # --- ENSURE DIRECTORIES EXIST ---
# Critical: Create data folders immediately to prevent DB initialization errors
for d in [DATA_DIR, PROJECTS_DIR, PERSONAS_DIR, FONTS_DIR]: for d in [DATA_DIR, PROJECTS_DIR, PERSONAS_DIR, FONTS_DIR]:
if not os.path.exists(d): os.makedirs(d, exist_ok=True) if not os.path.exists(d): os.makedirs(d, exist_ok=True)
# --- AUTHENTICATION --- # --- AUTHENTICATION ---
GOOGLE_CREDS = os.getenv("GOOGLE_APPLICATION_CREDENTIALS") GOOGLE_CREDS = os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
if GOOGLE_CREDS: if GOOGLE_CREDS:
# Resolve to absolute path relative to this config file if not absolute
if not os.path.isabs(GOOGLE_CREDS): if not os.path.isabs(GOOGLE_CREDS):
base = os.path.dirname(os.path.abspath(__file__)) GOOGLE_CREDS = os.path.join(BASE_DIR, GOOGLE_CREDS)
GOOGLE_CREDS = os.path.join(base, GOOGLE_CREDS)
if os.path.exists(GOOGLE_CREDS): if os.path.exists(GOOGLE_CREDS):
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = GOOGLE_CREDS os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = GOOGLE_CREDS
@@ -65,4 +65,4 @@ LENGTH_DEFINITIONS = {
} }
# --- SYSTEM --- # --- SYSTEM ---
VERSION = "1.4.0" VERSION = "1.4.0"

View File

@@ -2,7 +2,7 @@ import os
import json import json
import datetime import datetime
import time import time
import config from core import config
import threading import threading
import re import re
@@ -35,7 +35,6 @@ def update_progress(percent):
def clean_json(text): def clean_json(text):
text = text.replace("```json", "").replace("```", "").strip() text = text.replace("```json", "").replace("```", "").strip()
# Robust extraction: find first { or [ and last } or ]
start_obj = text.find('{') start_obj = text.find('{')
start_arr = text.find('[') start_arr = text.find('[')
if start_obj == -1 and start_arr == -1: return text 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] return text[start_arr:text.rfind(']')+1]
def sanitize_filename(name): def sanitize_filename(name):
"""Sanitizes a string to be safe for filenames."""
if not name: return "Untitled" if not name: return "Untitled"
safe = "".join([c for c in name if c.isalnum() or c=='_']).replace(" ", "_") safe = "".join([c for c in name if c.isalnum() or c=='_']).replace(" ", "_")
return safe if safe else "Untitled" return safe if safe else "Untitled"
def chapter_sort_key(ch): def chapter_sort_key(ch):
"""Sort key for chapters handling integers, strings, Prologue, and Epilogue."""
num = ch.get('num', 0) num = ch.get('num', 0)
if isinstance(num, int): return num if isinstance(num, int): return num
if isinstance(num, str) and num.isdigit(): return int(num) if isinstance(num, str) and num.isdigit(): return int(num)
@@ -61,7 +58,6 @@ def chapter_sort_key(ch):
return 999 return 999
def get_sorted_book_folders(run_dir): 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 [] 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_")] 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): def sort_key(d):
@@ -70,31 +66,26 @@ def get_sorted_book_folders(run_dir):
return 0 return 0
return sorted(subdirs, key=sort_key) return sorted(subdirs, key=sort_key)
# --- SHARED UTILS ---
def log_banner(phase, title): def log_banner(phase, title):
"""Log a visually distinct phase separator line."""
log(phase, f"{'' * 18} {title} {'' * 18}") log(phase, f"{'' * 18} {title} {'' * 18}")
def log(phase, msg): def log(phase, msg):
timestamp = datetime.datetime.now().strftime('%H:%M:%S') timestamp = datetime.datetime.now().strftime('%H:%M:%S')
line = f"[{timestamp}] {phase:<15} | {msg}" line = f"[{timestamp}] {phase:<15} | {msg}"
print(line) print(line)
# Write to thread-specific log file if set
if getattr(_log_context, 'log_file', None): if getattr(_log_context, 'log_file', None):
with open(_log_context.log_file, "a", encoding="utf-8") as f: with open(_log_context.log_file, "a", encoding="utf-8") as f:
f.write(line + "\n") f.write(line + "\n")
# Trigger callback if set (e.g. for Database logging)
if getattr(_log_context, 'callback', None): if getattr(_log_context, 'callback', None):
try: _log_context.callback(phase, msg) try: _log_context.callback(phase, msg)
except: pass except: pass
def load_json(path): def load_json(path):
return json.load(open(path, 'r')) if os.path.exists(path) else None return json.load(open(path, 'r')) if os.path.exists(path) else None
def create_default_personas(): 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_DIR): os.makedirs(config.PERSONAS_DIR)
if not os.path.exists(config.PERSONAS_FILE): if not os.path.exists(config.PERSONAS_FILE):
try: try:
@@ -102,7 +93,6 @@ def create_default_personas():
except: pass except: pass
def get_length_presets(): def get_length_presets():
"""Returns a dict mapping Label -> Settings for use in main.py"""
presets = {} presets = {}
for k, v in config.LENGTH_DEFINITIONS.items(): for k, v in config.LENGTH_DEFINITIONS.items():
presets[v['label']] = v presets[v['label']] = v
@@ -145,65 +135,53 @@ def get_latest_run_folder(base_name):
return os.path.join(base_name, runs[-1]) return os.path.join(base_name, runs[-1])
def update_pricing(model_name, cost_str): 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 if not model_name or not cost_str or cost_str == 'N/A': return
try: try:
# Look for patterns like "$0.075 Input" or "$3.50/1M"
# Default to 0.0
in_cost = 0.0 in_cost = 0.0
out_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) prices = re.findall(r'(?:\$|USD)\s*([0-9]+\.?[0-9]*)', cost_str, re.IGNORECASE)
if len(prices) >= 2: if len(prices) >= 2:
in_cost = float(prices[0]) in_cost = float(prices[0])
out_cost = float(prices[1]) out_cost = float(prices[1])
elif len(prices) == 1: elif len(prices) == 1:
in_cost = float(prices[0]) 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: if in_cost > 0:
PRICING_CACHE[model_name] = {"input": in_cost, "output": out_cost} 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: except:
pass pass
def calculate_cost(model_label, input_tokens, output_tokens, image_count=0): def calculate_cost(model_label, input_tokens, output_tokens, image_count=0):
cost = 0.0 cost = 0.0
m = model_label.lower() m = model_label.lower()
# Check dynamic cache first
if model_label in PRICING_CACHE: if model_label in PRICING_CACHE:
rates = PRICING_CACHE[model_label] rates = PRICING_CACHE[model_label]
cost = (input_tokens / 1_000_000 * rates['input']) + (output_tokens / 1_000_000 * rates['output']) cost = (input_tokens / 1_000_000 * rates['input']) + (output_tokens / 1_000_000 * rates['output'])
elif 'imagen' in m or image_count > 0: elif 'imagen' in m or image_count > 0:
cost = (image_count * 0.04) cost = (image_count * 0.04)
else: else:
# Fallbacks
if 'flash' in m: if 'flash' in m:
cost = (input_tokens / 1_000_000 * 0.075) + (output_tokens / 1_000_000 * 0.30) cost = (input_tokens / 1_000_000 * 0.075) + (output_tokens / 1_000_000 * 0.30)
elif 'pro' in m or 'logic' in m: elif 'pro' in m or 'logic' in m:
cost = (input_tokens / 1_000_000 * 3.50) + (output_tokens / 1_000_000 * 10.50) cost = (input_tokens / 1_000_000 * 3.50) + (output_tokens / 1_000_000 * 10.50)
return round(cost, 6) return round(cost, 6)
def log_usage(folder, model_label, usage_metadata=None, image_count=0): def log_usage(folder, model_label, usage_metadata=None, image_count=0):
if not folder or not os.path.exists(folder): return if not folder or not os.path.exists(folder): return
log_path = os.path.join(folder, "usage_log.json") log_path = os.path.join(folder, "usage_log.json")
input_tokens = 0 input_tokens = 0
output_tokens = 0 output_tokens = 0
if usage_metadata: if usage_metadata:
try: try:
input_tokens = usage_metadata.prompt_token_count input_tokens = usage_metadata.prompt_token_count
output_tokens = usage_metadata.candidates_token_count output_tokens = usage_metadata.candidates_token_count
except: pass except: pass
# Calculate Cost
cost = calculate_cost(model_label, input_tokens, output_tokens, image_count) cost = calculate_cost(model_label, input_tokens, output_tokens, image_count)
entry = { entry = {
@@ -217,43 +195,41 @@ def log_usage(folder, model_label, usage_metadata=None, image_count=0):
} }
data = {"log": [], "totals": {"input_tokens": 0, "output_tokens": 0, "images": 0, "est_cost_usd": 0.0}} data = {"log": [], "totals": {"input_tokens": 0, "output_tokens": 0, "images": 0, "est_cost_usd": 0.0}}
if os.path.exists(log_path): if os.path.exists(log_path):
try: try:
loaded = json.load(open(log_path, 'r')) loaded = json.load(open(log_path, 'r'))
if isinstance(loaded, list): data["log"] = loaded if isinstance(loaded, list): data["log"] = loaded
elif isinstance(loaded, dict): data = loaded elif isinstance(loaded, dict): data = loaded
except: pass except: pass
data["log"].append(entry) data["log"].append(entry)
# Recalculate totals
t_in = sum(x.get('input_tokens', 0) for x in data["log"]) 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_out = sum(x.get('output_tokens', 0) for x in data["log"])
t_img = sum(x.get('images', 0) for x in data["log"]) t_img = sum(x.get('images', 0) for x in data["log"])
total_cost = 0.0 total_cost = 0.0
for x in data["log"]: for x in data["log"]:
if 'cost' in x: if 'cost' in x:
total_cost += x['cost'] total_cost += x['cost']
else: else:
# Fallback calculation for old logs without explicit cost field
c = 0.0 c = 0.0
mx = x.get('model', '').lower() mx = x.get('model', '').lower()
ix = x.get('input_tokens', 0) ix = x.get('input_tokens', 0)
ox = x.get('output_tokens', 0) ox = x.get('output_tokens', 0)
imgx = x.get('images', 0) imgx = x.get('images', 0)
if 'flash' in mx: c = (ix / 1_000_000 * 0.075) + (ox / 1_000_000 * 0.30) if 'flash' in mx: c = (ix / 1_000_000 * 0.075) + (ox / 1_000_000 * 0.30)
elif 'pro' in mx or 'logic' in mx: c = (ix / 1_000_000 * 3.50) + (ox / 1_000_000 * 10.50) elif 'pro' in mx or 'logic' in mx: c = (ix / 1_000_000 * 3.50) + (ox / 1_000_000 * 10.50)
elif 'imagen' in mx or imgx > 0: c = (imgx * 0.04) elif 'imagen' in mx or imgx > 0: c = (imgx * 0.04)
total_cost += c total_cost += c
data["totals"] = { data["totals"] = {
"input_tokens": t_in, "input_tokens": t_in,
"output_tokens": t_out, "output_tokens": t_out,
"images": t_img, "images": t_img,
"est_cost_usd": round(total_cost, 4) "est_cost_usd": round(total_cost, 4)
} }
with open(log_path, 'w') as f: json.dump(data, f, indent=2) with open(log_path, 'w') as f: json.dump(data, f, indent=2)

View File

@@ -18,11 +18,14 @@ services:
# --- DEVELOPMENT (Code Sync) --- # --- DEVELOPMENT (Code Sync) ---
# UNCOMMENT these lines only if you are developing and want to see changes instantly. # 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. # 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 # - ./templates:/app/templates
# - ./main.py:/app/main.py
# - ./wizard.py:/app/wizard.py
# - ./config.py:/app/config.py
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- GOOGLE_APPLICATION_CREDENTIALS=/app/credentials.json - GOOGLE_APPLICATION_CREDENTIALS=/app/credentials.json

0
export/__init__.py Normal file
View File

View File

@@ -2,7 +2,8 @@ import os
import markdown import markdown
from docx import Document from docx import Document
from ebooklib import epub from ebooklib import epub
from . import utils from core import utils
def create_readme(folder, bp): def create_readme(folder, bp):
meta = bp['book_metadata'] meta = bp['book_metadata']
@@ -10,30 +11,28 @@ 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')}""" 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) with open(os.path.join(folder, "README.md"), "w") as f: f.write(content)
def compile_files(bp, ms, folder): def compile_files(bp, ms, folder):
utils.log("SYSTEM", "Compiling EPUB and DOCX...") utils.log("SYSTEM", "Compiling EPUB and DOCX...")
meta = bp.get('book_metadata', {}) meta = bp.get('book_metadata', {})
title = meta.get('title', 'Untitled') title = meta.get('title', 'Untitled')
if meta.get('filename'): if meta.get('filename'):
safe = meta['filename'] safe = meta['filename']
else: else:
safe = utils.sanitize_filename(title) safe = utils.sanitize_filename(title)
doc = Document(); doc.add_heading(title, 0) doc = Document(); doc.add_heading(title, 0)
book = epub.EpubBook(); book.set_title(title); spine = ['nav'] book = epub.EpubBook(); book.set_title(title); spine = ['nav']
# Add Cover if exists
cover_path = os.path.join(folder, "cover.png") cover_path = os.path.join(folder, "cover.png")
if os.path.exists(cover_path): if os.path.exists(cover_path):
with open(cover_path, 'rb') as f: with open(cover_path, 'rb') as f:
book.set_cover("cover.png", f.read()) book.set_cover("cover.png", f.read())
# Ensure manuscript is sorted correctly before compiling
ms.sort(key=utils.chapter_sort_key) ms.sort(key=utils.chapter_sort_key)
for c in ms: for c in ms:
# Determine filename/type
num_str = str(c['num']).lower() num_str = str(c['num']).lower()
if num_str == '0' or 'prologue' in num_str: if num_str == '0' or 'prologue' in num_str:
filename = "prologue.xhtml" filename = "prologue.xhtml"
@@ -45,14 +44,13 @@ def compile_files(bp, ms, folder):
filename = f"ch_{c['num']}.xhtml" filename = f"ch_{c['num']}.xhtml"
default_header = f"Ch {c['num']}: {c['title']}" default_header = f"Ch {c['num']}: {c['title']}"
# Check for AI-generated header in content
content = c['content'].strip() content = c['content'].strip()
clean_content = content.replace("```markdown", "").replace("```", "").strip() clean_content = content.replace("```markdown", "").replace("```", "").strip()
lines = clean_content.split('\n') lines = clean_content.split('\n')
ai_header = None ai_header = None
body_content = clean_content body_content = clean_content
if lines and lines[0].strip().startswith('# '): if lines and lines[0].strip().startswith('# '):
ai_header = lines[0].strip().replace('#', '').strip() ai_header = lines[0].strip().replace('#', '').strip()
header = ai_header header = ai_header
@@ -62,16 +60,16 @@ def compile_files(bp, ms, folder):
doc.add_heading(header, 1) doc.add_heading(header, 1)
doc.add_paragraph(body_content) doc.add_paragraph(body_content)
ch = epub.EpubHtml(title=header, file_name=filename) ch = epub.EpubHtml(title=header, file_name=filename)
clean_content = clean_content.replace(f"{folder}\\", "").replace(f"{folder}/", "") clean_content = clean_content.replace(f"{folder}\\", "").replace(f"{folder}/", "")
html_content = markdown.markdown(clean_content) html_content = markdown.markdown(clean_content)
ch.content = html_content if ai_header else f"<h1>{header}</h1>{html_content}" ch.content = html_content if ai_header else f"<h1>{header}</h1>{html_content}"
book.add_item(ch); spine.append(ch) book.add_item(ch); spine.append(ch)
doc.save(os.path.join(folder, f"{safe}.docx")) doc.save(os.path.join(folder, f"{safe}.docx"))
book.spine = spine; book.add_item(epub.EpubNcx()); book.add_item(epub.EpubNav()) book.spine = spine; book.add_item(epub.EpubNcx()); book.add_item(epub.EpubNav())
epub.write_epub(os.path.join(folder, f"{safe}.epub"), book, {}) epub.write_epub(os.path.join(folder, f"{safe}.epub"), book, {})
create_readme(folder, bp) create_readme(folder, bp)

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 shutil
import textwrap import textwrap
import subprocess import subprocess
import requests from core import utils
from . import utils from ai import models as ai_models
import config from marketing.fonts import download_font
from modules import ai
from rich.prompt import Confirm
try: try:
from PIL import Image, ImageDraw, ImageFont, ImageStat from PIL import Image, ImageDraw, ImageFont, ImageStat
@@ -16,59 +14,6 @@ try:
except ImportError: except ImportError:
HAS_PIL = False 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): def evaluate_image_quality(image_path, prompt, model, folder=None):
if not HAS_PIL: return None, "PIL not installed" 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') return data.get('score'), data.get('reason')
except Exception as e: return None, str(e) 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): def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
if not HAS_PIL: if not HAS_PIL:
@@ -141,7 +39,6 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
utils.log("MARKETING", "Generating cover...") utils.log("MARKETING", "Generating cover...")
meta = bp.get('book_metadata', {}) meta = bp.get('book_metadata', {})
series = bp.get('series_metadata', {})
orientation = meta.get('style', {}).get('page_orientation', 'Portrait') orientation = meta.get('style', {}).get('page_orientation', 'Portrait')
ar = "3:4" ar = "3:4"
@@ -156,31 +53,29 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
if 'characters' in tracking: if 'characters' in tracking:
visual_context += f"Character Appearances: {json.dumps(tracking['characters'])}\n" visual_context += f"Character Appearances: {json.dumps(tracking['characters'])}\n"
# Feedback Analysis
regenerate_image = True regenerate_image = True
design_instruction = "" 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: if os.path.exists(os.path.join(folder, "cover_art.png")) and not feedback:
regenerate_image = False regenerate_image = False
if feedback and feedback.strip(): if feedback and feedback.strip():
utils.log("MARKETING", f"Analyzing feedback: '{feedback}'...") utils.log("MARKETING", f"Analyzing feedback: '{feedback}'...")
analysis_prompt = f""" analysis_prompt = f"""
ROLE: Design Assistant ROLE: Design Assistant
TASK: Analyze user feedback on cover. TASK: Analyze user feedback on cover.
FEEDBACK: "{feedback}" FEEDBACK: "{feedback}"
DECISION: DECISION:
1. Keep the current background image but change text/layout/color (REGENERATE_LAYOUT). 1. Keep the current background image but change text/layout/color (REGENERATE_LAYOUT).
2. Create a completely new background image (REGENERATE_IMAGE). 2. Create a completely new background image (REGENERATE_IMAGE).
OUTPUT_FORMAT (JSON): {{ "action": "REGENERATE_LAYOUT" or "REGENERATE_IMAGE", "instruction": "Specific instruction for Art Director" }} OUTPUT_FORMAT (JSON): {{ "action": "REGENERATE_LAYOUT" or "REGENERATE_IMAGE", "instruction": "Specific instruction for Art Director" }}
""" """
try: try:
resp = ai.model_logic.generate_content(analysis_prompt) resp = ai_models.model_logic.generate_content(analysis_prompt)
utils.log_usage(folder, ai.model_logic.name, resp.usage_metadata) utils.log_usage(folder, ai_models.model_logic.name, resp.usage_metadata)
decision = json.loads(utils.clean_json(resp.text)) decision = json.loads(utils.clean_json(resp.text))
if decision.get('action') == 'REGENERATE_LAYOUT': if decision.get('action') == 'REGENERATE_LAYOUT':
regenerate_image = False regenerate_image = False
@@ -191,7 +86,6 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
genre = meta.get('genre', 'Fiction') genre = meta.get('genre', 'Fiction')
tone = meta.get('style', {}).get('tone', 'Balanced') tone = meta.get('style', {}).get('tone', 'Balanced')
# Map genre to visual style suggestion
genre_style_map = { genre_style_map = {
'thriller': 'dark, cinematic, high-contrast photography style', 'thriller': 'dark, cinematic, high-contrast photography style',
'mystery': 'moody, atmospheric, noir-inspired painting', 'mystery': 'moody, atmospheric, noir-inspired painting',
@@ -237,46 +131,43 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
}} }}
""" """
try: try:
response = ai.model_artist.generate_content(design_prompt) response = ai_models.model_artist.generate_content(design_prompt)
utils.log_usage(folder, ai.model_artist.name, response.usage_metadata) utils.log_usage(folder, ai_models.model_artist.name, response.usage_metadata)
design = json.loads(utils.clean_json(response.text)) design = json.loads(utils.clean_json(response.text))
bg_color = design.get('primary_color', '#252570') 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')}") 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: with open(os.path.join(folder, "cover_art_prompt.txt"), "w") as f:
f.write(art_prompt) f.write(art_prompt)
img = None img = None
image_generated = False
width, height = 600, 900 width, height = 600, 900
best_img_score = 0 best_img_score = 0
best_img_path = None best_img_path = None
MAX_IMG_ATTEMPTS = 3 MAX_IMG_ATTEMPTS = 3
if regenerate_image: if regenerate_image:
for i in range(1, MAX_IMG_ATTEMPTS + 1): for i in range(1, MAX_IMG_ATTEMPTS + 1):
utils.log("MARKETING", f"Generating cover art (Attempt {i}/{MAX_IMG_ATTEMPTS})...") utils.log("MARKETING", f"Generating cover art (Attempt {i}/{MAX_IMG_ATTEMPTS})...")
try: 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" status = "success"
try: 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: except Exception as e:
err_lower = str(e).lower() err_lower = str(e).lower()
# Try fast imagen variant before falling back to legacy if ai_models.HAS_VERTEX and ("resource" in err_lower or "quota" in err_lower):
if ai.HAS_VERTEX and ("resource" in err_lower or "quota" in err_lower):
try: try:
utils.log("MARKETING", "⚠️ Imagen 3 failed. Trying Imagen 3 Fast...") 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) result = fb_model.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar)
status = "success_fast" status = "success_fast"
except Exception: except Exception:
utils.log("MARKETING", "⚠️ Imagen 3 Fast failed. Trying Imagen 2...") 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) result = fb_model.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar)
status = "success_fallback" status = "success_fallback"
else: 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"Score 1-10. Deduct 3 points if any text/watermarks are visible. "
f"Deduct 2 if the image is blurry or has deformed anatomy." 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 if score is None: score = 0
utils.log("MARKETING", f" -> Image Score: {score}/10. Critique: {critique}") 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)) else: subprocess.call(('xdg-open', attempt_path))
except: pass except: pass
from rich.prompt import Confirm
if Confirm.ask(f"Accept cover attempt {i} (Score: {score})?", default=True): if Confirm.ask(f"Accept cover attempt {i} (Score: {score})?", default=True):
best_img_path = attempt_path best_img_path = attempt_path
break break
@@ -317,12 +209,10 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
utils.log("MARKETING", "User rejected cover. Retrying...") utils.log("MARKETING", "User rejected cover. Retrying...")
continue continue
# Only keep as best if score meets minimum quality bar
if score >= 5 and score > best_img_score: if score >= 5 and score > best_img_score:
best_img_score = score best_img_score = score
best_img_path = attempt_path best_img_path = attempt_path
elif best_img_path is None and score > 0: 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_score = score
best_img_path = attempt_path 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.") utils.log("MARKETING", " -> High quality image accepted.")
break break
# Refine prompt based on critique keywords
prompt_additions = [] prompt_additions = []
critique_lower = critique.lower() if critique else "" critique_lower = critique.lower() if critique else ""
if "scar" in critique_lower or "deform" in critique_lower: if "scar" in critique_lower or "deform" in critique_lower:
@@ -351,106 +240,102 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
if best_img_path != final_art_path: if best_img_path != final_art_path:
shutil.copy(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") img = Image.open(final_art_path).resize((width, height)).convert("RGB")
image_generated = True
else: else:
utils.log("MARKETING", "Falling back to solid color cover.") utils.log("MARKETING", "Falling back to solid color cover.")
img = Image.new('RGB', (width, height), color=bg_color) img = Image.new('RGB', (width, height), color=bg_color)
utils.log_image_attempt(folder, "cover", art_prompt, "cover.png", "fallback_solid") utils.log_image_attempt(folder, "cover", art_prompt, "cover.png", "fallback_solid")
else: else:
# Load existing art
final_art_path = os.path.join(folder, "cover_art.png") final_art_path = os.path.join(folder, "cover_art.png")
if os.path.exists(final_art_path): if os.path.exists(final_art_path):
utils.log("MARKETING", "Using existing cover art (Layout update only).") utils.log("MARKETING", "Using existing cover art (Layout update only).")
img = Image.open(final_art_path).resize((width, height)).convert("RGB") img = Image.open(final_art_path).resize((width, height)).convert("RGB")
else: else:
utils.log("MARKETING", "Existing art not found. Forcing regeneration.") 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) img = Image.new('RGB', (width, height), color=bg_color)
font_path = download_font(design.get('font_name') or 'Arial') font_path = download_font(design.get('font_name') or 'Arial')
best_layout_score = 0 best_layout_score = 0
best_layout_path = None best_layout_path = None
base_layout_prompt = f""" base_layout_prompt = f"""
ROLE: Graphic Designer ROLE: Graphic Designer
TASK: Determine text layout coordinates for a 600x900 cover. TASK: Determine text layout coordinates for a 600x900 cover.
METADATA: METADATA:
- TITLE: {meta.get('title')} - TITLE: {meta.get('title')}
- AUTHOR: {meta.get('author')} - AUTHOR: {meta.get('author')}
- GENRE: {meta.get('genre')} - GENRE: {meta.get('genre')}
CONSTRAINT: Do NOT place text over faces. CONSTRAINT: Do NOT place text over faces.
OUTPUT_FORMAT (JSON): OUTPUT_FORMAT (JSON):
{{ {{
"title": {{ "x": Int, "y": Int, "font_size": Int, "font_name": "String", "color": "#Hex" }}, "title": {{ "x": Int, "y": Int, "font_size": Int, "font_name": "String", "color": "#Hex" }},
"author": {{ "x": Int, "y": Int, "font_size": Int, "font_name": "String", "color": "#Hex" }} "author": {{ "x": Int, "y": Int, "font_size": Int, "font_name": "String", "color": "#Hex" }}
}} }}
""" """
if feedback: if feedback:
base_layout_prompt += f"\nUSER FEEDBACK: {feedback}\nAdjust layout/colors accordingly." base_layout_prompt += f"\nUSER FEEDBACK: {feedback}\nAdjust layout/colors accordingly."
layout_prompt = base_layout_prompt layout_prompt = base_layout_prompt
for attempt in range(1, 6): for attempt in range(1, 6):
utils.log("MARKETING", f"Designing text layout (Attempt {attempt}/5)...") utils.log("MARKETING", f"Designing text layout (Attempt {attempt}/5)...")
try: try:
response = ai.model_writer.generate_content([layout_prompt, img]) response = ai_models.model_writer.generate_content([layout_prompt, img])
utils.log_usage(folder, ai.model_writer.name, response.usage_metadata) utils.log_usage(folder, ai_models.model_writer.name, response.usage_metadata)
layout = json.loads(utils.clean_json(response.text)) layout = json.loads(utils.clean_json(response.text))
if isinstance(layout, list): layout = layout[0] if layout else {} if isinstance(layout, list): layout = layout[0] if layout else {}
except Exception as e: except Exception as e:
utils.log("MARKETING", f"Layout generation failed: {e}") utils.log("MARKETING", f"Layout generation failed: {e}")
continue continue
img_copy = img.copy() img_copy = img.copy()
draw = ImageDraw.Draw(img_copy) draw = ImageDraw.Draw(img_copy)
def draw_element(key, text_override=None): def draw_element(key, text_override=None):
elem = layout.get(key) elem = layout.get(key)
if not elem: return if not elem: return
if isinstance(elem, list): elem = elem[0] if elem else {} if isinstance(elem, list): elem = elem[0] if elem else {}
text = text_override if text_override else elem.get('text') text = text_override if text_override else elem.get('text')
if not text: return if not text: return
f_name = elem.get('font_name') or 'Arial' f_name = elem.get('font_name') or 'Arial'
f_path = download_font(f_name) f_path = download_font(f_name)
try: try:
if f_path: font = ImageFont.truetype(f_path, elem.get('font_size', 40)) if f_path: font = ImageFont.truetype(f_path, elem.get('font_size', 40))
else: raise IOError("Font not found") else: raise IOError("Font not found")
except: font = ImageFont.load_default() except: font = ImageFont.load_default()
x, y = elem.get('x', 300), elem.get('y', 450) x, y = elem.get('x', 300), elem.get('y', 450)
color = elem.get('color') or '#FFFFFF' color = elem.get('color') or '#FFFFFF'
avg_char_w = font.getlength("A") avg_char_w = font.getlength("A")
wrap_w = int(550 / avg_char_w) if avg_char_w > 0 else 20 wrap_w = int(550 / avg_char_w) if avg_char_w > 0 else 20
lines = textwrap.wrap(text, width=wrap_w) lines = textwrap.wrap(text, width=wrap_w)
line_heights = [] line_heights = []
for l in lines: for l in lines:
bbox = draw.textbbox((0, 0), l, font=font) bbox = draw.textbbox((0, 0), l, font=font)
line_heights.append(bbox[3] - bbox[1] + 10) line_heights.append(bbox[3] - bbox[1] + 10)
total_h = sum(line_heights) total_h = sum(line_heights)
current_y = y - (total_h // 2) 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) bbox = draw.textbbox((0, 0), line, font=font)
lx = x - ((bbox[2] - bbox[0]) / 2) lx = x - ((bbox[2] - bbox[0]) / 2)
draw.text((lx, current_y), line, font=font, fill=color) 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('title', meta.get('title'))
draw_element('author', meta.get('author')) draw_element('author', meta.get('author'))
attempt_path = os.path.join(folder, f"cover_layout_attempt_{attempt}.png") attempt_path = os.path.join(folder, f"cover_layout_attempt_{attempt}.png")
img_copy.save(attempt_path) img_copy.save(attempt_path)
# Evaluate Layout
eval_prompt = f""" eval_prompt = f"""
Analyze the text layout for the book title '{meta.get('title')}'. Analyze the text layout for the book title '{meta.get('title')}'.
CHECKLIST: CHECKLIST:
@@ -458,19 +343,19 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
2. Is the contrast sufficient? 2. Is the contrast sufficient?
3. Does it look professional? 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 if score is None: score = 0
utils.log("MARKETING", f" -> Layout Score: {score}/10. Critique: {critique}") utils.log("MARKETING", f" -> Layout Score: {score}/10. Critique: {critique}")
if score > best_layout_score: if score > best_layout_score:
best_layout_score = score best_layout_score = score
best_layout_path = attempt_path best_layout_path = attempt_path
if score == 10: if score == 10:
utils.log("MARKETING", " -> Perfect layout accepted.") utils.log("MARKETING", " -> Perfect layout accepted.")
break break
layout_prompt = base_layout_prompt + f"\nCRITIQUE OF PREVIOUS ATTEMPT: {critique}\nAdjust position/color to fix this." layout_prompt = base_layout_prompt + f"\nCRITIQUE OF PREVIOUS ATTEMPT: {critique}\nAdjust position/color to fix this."
if best_layout_path: if best_layout_path:
@@ -478,7 +363,3 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
except Exception as e: except Exception as e:
utils.log("MARKETING", f"Cover generation failed: {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> <p class="text-muted">System management and user administration.</p>
</div> </div>
<div class="col-md-4 text-end"> <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('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('index') }}" class="btn btn-outline-secondary">Back to Dashboard</a> <a href="{{ url_for('project.index') }}" class="btn btn-outline-secondary">Back to Dashboard</a>
</div> </div>
</div> </div>
@@ -41,7 +41,7 @@
<td> <td>
{% if u.id != current_user.id %} {% 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.');"> <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> <i class="fas fa-user-secret"></i>
</a> </a>
<button class="btn btn-sm btn-outline-danger" title="Delete User"><i class="fas fa-trash"></i></button> <button class="btn btn-sm btn-outline-danger" title="Delete User"><i class="fas fa-trash"></i></button>
@@ -67,7 +67,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="text-muted small">Manage global AI writing rules and banned words.</p> <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>
</div> </div>

View File

@@ -7,7 +7,7 @@
<p class="text-muted">Aggregate cost analysis per user.</p> <p class="text-muted">Aggregate cost analysis per user.</p>
</div> </div>
<div class="col-md-4 text-end"> <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>
</div> </div>

View File

@@ -5,7 +5,7 @@
<div class="col-md-8"> <div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-4"> <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> <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>
<div class="card shadow-sm"> <div class="card shadow-sm">
@@ -36,7 +36,7 @@
<button type="submit" class="btn btn-primary btn-lg"> <button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-save me-2"></i>Save Guidelines <i class="fas fa-save me-2"></i>Save Guidelines
</button> </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 <i class="fas fa-magic me-2"></i>Auto-Refresh with AI
</button> </button>
</div> </div>

View File

@@ -28,7 +28,7 @@
{% if session.get('original_admin_id') %} {% 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;"> <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> <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> </div>
{% endif %} {% endif %}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4"> <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="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="fas fa-search me-2"></i>Consistency Report</h2> <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>
<div class="card shadow-sm mb-4"> <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> <h4 class="mb-0">{% if name %}Edit Persona: {{ name }}{% else %}New Persona{% endif %}</h4>
</div> </div>
<div class="card-body"> <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 }}"> <input type="hidden" name="old_name" value="{{ name }}">
<div class="mb-3"> <div class="mb-3">
@@ -72,7 +72,7 @@
</div> </div>
<div class="d-flex justify-content-between"> <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> <button type="submit" class="btn btn-primary">Save Persona</button>
</div> </div>
</form> </form>

View File

@@ -3,7 +3,7 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="fas fa-users me-2"></i>Author Personas</h2> <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>
<div class="row"> <div class="row">
@@ -16,8 +16,8 @@
<p class="card-text small">{{ p.bio[:150] }}...</p> <p class="card-text small">{{ p.bio[:150] }}...</p>
</div> </div>
<div class="card-footer bg-white border-top-0 d-flex justify-content-between"> <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> <a href="{{ url_for('persona.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?');"> <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> <button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form> </form>
</div> </div>

View File

@@ -164,7 +164,7 @@
</td> </td>
<td>${{ "%.4f"|format(r.cost) }}</td> <td>${{ "%.4f"|format(r.cost) }}</td>
<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' }} {{ 'View Active' if active_run and r.id == active_run.id and active_run.status in ['running', 'queued'] else 'View' }}
</a> </a>
{% if r.status in ['failed', 'cancelled', 'interrupted'] %} {% if r.status in ['failed', 'cancelled', 'interrupted'] %}

View File

@@ -7,12 +7,12 @@
<small class="text-muted">Run #{{ run.id }}</small> <small class="text-muted">Run #{{ run.id }}</small>
</div> </div>
<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."> <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 <i class="fas fa-sync me-2"></i>Sync Metadata
</button> </button>
</form> </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>
</div> </div>

View File

@@ -4,7 +4,7 @@
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
<h2><i class="fas fa-book me-2"></i>Run #{{ run.id }}</h2> <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>
<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"> <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')."> <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 <i class="fas fa-pen-fancy me-2"></i>Modify & Re-run
</button> </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>
</div> </div>
@@ -124,7 +124,7 @@
<div class="col-md-4 mb-3"> <div class="col-md-4 mb-3">
<div class="text-center"> <div class="text-center">
{% if book.cover %} {% 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 %} {% else %}
<div class="alert alert-secondary py-5"> <div class="alert alert-secondary py-5">
<i class="fas fa-image fa-3x mb-3"></i><br>No cover. <i class="fas fa-image fa-3x mb-3"></i><br>No cover.
@@ -132,7 +132,7 @@
{% endif %} {% endif %}
{% if loop.first %} {% 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> <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"> <button type="submit" class="btn btn-sm btn-outline-primary w-100">
<i class="fas fa-sync me-2"></i>Regenerate All <i class="fas fa-sync me-2"></i>Regenerate All
@@ -156,7 +156,7 @@
<h6 class="fw-bold">Artifacts</h6> <h6 class="fw-bold">Artifacts</h6>
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
{% for art in book.artifacts %} {% 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 }} <i class="fas fa-download me-1"></i> {{ art.name }}
</a> </a>
{% else %} {% else %}
@@ -164,10 +164,10 @@
{% endfor %} {% endfor %}
<div class="mt-3"> <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 <i class="fas fa-book-reader me-2"></i>Read & Edit
</a> </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 <i class="fas fa-search me-2"></i>Check Consistency
</a> </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."> <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 --> <!-- Revise Book Modal -->
<div class="modal fade" id="reviseBookModal{{ loop.index }}" tabindex="-1"> <div class="modal fade" id="reviseBookModal{{ loop.index }}" tabindex="-1">
<div class="modal-dialog"> <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"> <div class="modal-header">
<h5 class="modal-title">Revise Book</h5> <h5 class="modal-title">Revise Book</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <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> <p class="text-muted">AI Model Health, Selection Reasoning, and Availability.</p>
</div> </div>
<div class="col-md-4 text-end"> <div class="col-md-4 text-end">
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary me-2">Back to Dashboard</a> <a href="{{ url_for('project.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?');"> <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"> <button type="submit" class="btn btn-primary">
<i class="fas fa-sync me-2"></i>Refresh & Optimize <i class="fas fa-sync me-2"></i>Refresh & Optimize
</button> </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,45 +4,47 @@ from datetime import datetime
db = SQLAlchemy() db = SQLAlchemy()
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(150), unique=True, nullable=False) username = db.Column(db.String(150), unique=True, nullable=False)
password = db.Column(db.String(150), 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) total_spend = db.Column(db.Float, default=0.0)
is_admin = db.Column(db.Boolean, default=False) is_admin = db.Column(db.Boolean, default=False)
class Project(db.Model): class Project(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
name = db.Column(db.String(150), nullable=False) name = db.Column(db.String(150), nullable=False)
folder_path = db.Column(db.String(300), nullable=False) folder_path = db.Column(db.String(300), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relationships
runs = db.relationship('Run', backref='project', lazy=True, cascade="all, delete-orphan") runs = db.relationship('Run', backref='project', lazy=True, cascade="all, delete-orphan")
class Run(db.Model): class Run(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False) 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) start_time = db.Column(db.DateTime, default=datetime.utcnow)
end_time = db.Column(db.DateTime, nullable=True) end_time = db.Column(db.DateTime, nullable=True)
log_file = db.Column(db.String(300), nullable=True) log_file = db.Column(db.String(300), nullable=True)
cost = db.Column(db.Float, default=0.0) cost = db.Column(db.Float, default=0.0)
progress = db.Column(db.Integer, default=0) progress = db.Column(db.Integer, default=0)
# Relationships
logs = db.relationship('LogEntry', backref='run', lazy=True, cascade="all, delete-orphan") logs = db.relationship('LogEntry', backref='run', lazy=True, cascade="all, delete-orphan")
def duration(self): def duration(self):
if self.end_time and self.start_time: if self.end_time and self.start_time:
return str(self.end_time - self.start_time).split('.')[0] return str(self.end_time - self.start_time).split('.')[0]
return "Running..." return "Running..."
class LogEntry(db.Model): class LogEntry(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
run_id = db.Column(db.Integer, db.ForeignKey('run.id'), nullable=False) run_id = db.Column(db.Integer, db.ForeignKey('run.id'), nullable=False)
timestamp = db.Column(db.DateTime, default=datetime.utcnow) timestamp = db.Column(db.DateTime, default=datetime.utcnow)
phase = db.Column(db.String(50)) phase = db.Column(db.String(50))
message = db.Column(db.Text) message = db.Column(db.Text)

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 import shutil
from datetime import datetime from datetime import datetime
from huey import SqliteHuey from huey import SqliteHuey
from .web_db import db, Run, User, Project from web.db import db, Run, User, Project
from . import utils from core import utils, config
import config from ai import models as ai_models
from . import story, ai, marketing, export 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) # Configure Huey (Task Queue)
huey = SqliteHuey('bookapp_queue', filename=os.path.join(config.DATA_DIR, 'queue.db')) huey = SqliteHuey('bookapp_queue', filename=os.path.join(config.DATA_DIR, 'queue.db'))
@@ -18,7 +21,7 @@ def db_log_callback(db_path, run_id, phase, msg):
for _ in range(5): for _ in range(5):
try: try:
with sqlite3.connect(db_path, timeout=5) as conn: with sqlite3.connect(db_path, timeout=5) as conn:
conn.execute("INSERT INTO log_entry (run_id, timestamp, phase, message) VALUES (?, ?, ?, ?)", conn.execute("INSERT INTO log_entry (run_id, timestamp, phase, message) VALUES (?, ?, ?, ?)",
(run_id, datetime.utcnow(), phase, str(msg))) (run_id, datetime.utcnow(), phase, str(msg)))
break break
except sqlite3.OperationalError: except sqlite3.OperationalError:
@@ -42,85 +45,74 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
""" """
# 1. Setup Logging # 1. Setup Logging
log_filename = f"system_log_{run_id}.txt" 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) initial_log = os.path.join(project_path, log_filename)
utils.set_log_file(initial_log) utils.set_log_file(initial_log)
# Hook up Database Logging # Hook up Database Logging
db_path = os.path.join(config.DATA_DIR, "bookapp.db") db_path = os.path.join(config.DATA_DIR, "bookapp.db")
utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m)) utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m))
utils.set_progress_callback(lambda p: db_progress_callback(db_path, run_id, p)) utils.set_progress_callback(lambda p: db_progress_callback(db_path, run_id, p))
# Set Status to Running # Set Status to Running
try: try:
with sqlite3.connect(db_path, timeout=10) as conn: with sqlite3.connect(db_path, timeout=10) as conn:
conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,)) conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,))
except: pass except: pass
utils.log("SYSTEM", f"Starting Job #{run_id}") utils.log("SYSTEM", f"Starting Job #{run_id}")
try: try:
# 1.1 Handle Feedback / Modification (Re-run logic) # 1.1 Handle Feedback / Modification (Re-run logic)
if feedback and source_run_id: if feedback and source_run_id:
utils.log("SYSTEM", f"Applying feedback to Run #{source_run_id}: '{feedback}'") 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) 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: if bible_data:
try: try:
ai.init_models() ai_setup.init_models()
new_bible = story.refine_bible(bible_data, feedback, project_path) new_bible = bible_tracker.refine_bible(bible_data, feedback, project_path)
if new_bible: if new_bible:
bible_data = 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) with open(bible_path, 'w') as f: json.dump(bible_data, f, indent=2)
utils.log("SYSTEM", "Bible updated with feedback.") utils.log("SYSTEM", "Bible updated with feedback.")
except Exception as e: except Exception as e:
utils.log("ERROR", f"Failed to refine bible: {e}") utils.log("ERROR", f"Failed to refine bible: {e}")
# 1.2 Keep Cover Art Logic # 1.2 Keep Cover Art Logic
if keep_cover and os.path.exists(source_run_dir): if keep_cover:
utils.log("SYSTEM", "Attempting to preserve cover art...") source_run_dir = os.path.join(project_path, "runs", f"run_{source_run_id}")
if os.path.exists(source_run_dir):
# We need to predict the new folder names to place the covers utils.log("SYSTEM", "Attempting to preserve cover art...")
# main.py uses: Book_{n}_{safe_title}
current_run_dir = os.path.join(project_path, "runs", f"run_{run_id}") 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) if not os.path.exists(current_run_dir): os.makedirs(current_run_dir)
# Map Source Books -> Target Books by Book Number source_books = {}
source_books = {} for d in os.listdir(source_run_dir):
for d in os.listdir(source_run_dir): if d.startswith("Book_") and os.path.isdir(os.path.join(source_run_dir, d)):
if d.startswith("Book_") and os.path.isdir(os.path.join(source_run_dir, d)): parts = d.split('_')
parts = d.split('_') if len(parts) > 1 and parts[1].isdigit():
if len(parts) > 1 and parts[1].isdigit(): source_books[int(parts[1])] = os.path.join(source_run_dir, d)
source_books[int(parts[1])] = os.path.join(source_run_dir, d)
if bible_data and 'books' in bible_data:
if bible_data and 'books' in bible_data: for i, book in enumerate(bible_data['books']):
for i, book in enumerate(bible_data['books']): b_num = book.get('book_number', i+1)
b_num = book.get('book_number', i+1) if b_num in source_books:
if b_num in source_books: src_folder = source_books[b_num]
# Found matching book in source safe_title = utils.sanitize_filename(book.get('title', f"Book_{b_num}"))
src_folder = source_books[b_num] target_folder = os.path.join(current_run_dir, f"Book_{b_num}_{safe_title}")
# Predict Target Folder os.makedirs(target_folder, exist_ok=True)
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}") src_cover = os.path.join(src_folder, "cover.png")
if os.path.exists(src_cover):
os.makedirs(target_folder, exist_ok=True) shutil.copy2(src_cover, os.path.join(target_folder, "cover.png"))
if os.path.exists(os.path.join(src_folder, "cover_art.png")):
# Copy Cover shutil.copy2(os.path.join(src_folder, "cover_art.png"), os.path.join(target_folder, "cover_art.png"))
src_cover = os.path.join(src_folder, "cover.png") utils.log("SYSTEM", f" -> Copied cover for Book {b_num}")
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}")
# 1.5 Copy Forward Logic (Series Optimization) # 1.5 Copy Forward Logic (Series Optimization)
is_series = False is_series = False
@@ -130,27 +122,23 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
is_series = bible_data.get('project_metadata', {}).get('is_series', False) is_series = bible_data.get('project_metadata', {}).get('is_series', False)
runs_dir = os.path.join(project_path, "runs") 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): 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}"] 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) all_runs.sort(key=lambda x: int(x.split('_')[1]) if x.split('_')[1].isdigit() else 0)
if all_runs: if all_runs:
latest_run_dir = os.path.join(runs_dir, all_runs[-1]) latest_run_dir = os.path.join(runs_dir, all_runs[-1])
current_run_dir = os.path.join(runs_dir, f"run_{run_id}") current_run_dir = os.path.join(runs_dir, f"run_{run_id}")
os.makedirs(current_run_dir, exist_ok=True) os.makedirs(current_run_dir, exist_ok=True)
utils.log("SYSTEM", f"Checking previous run ({all_runs[-1]}) for completed books...") utils.log("SYSTEM", f"Checking previous run ({all_runs[-1]}) for completed books...")
for item in os.listdir(latest_run_dir): 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 item.startswith("Book_") and os.path.isdir(os.path.join(latest_run_dir, item)):
if exclude_folders and item in exclude_folders: if exclude_folders and item in exclude_folders:
utils.log("SYSTEM", f" -> Skipping copy of {item} (Target for revision).") utils.log("SYSTEM", f" -> Skipping copy of {item} (Target for revision).")
continue continue
if os.path.exists(os.path.join(latest_run_dir, item, "manuscript.json")): if os.path.exists(os.path.join(latest_run_dir, item, "manuscript.json")):
src = os.path.join(latest_run_dir, item) src = os.path.join(latest_run_dir, item)
dst = os.path.join(current_run_dir, item) dst = os.path.join(current_run_dir, item)
@@ -161,38 +149,32 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
utils.log("SYSTEM", f" -> Failed to copy {item}: {e}") utils.log("SYSTEM", f" -> Failed to copy {item}: {e}")
# 2. Run Generation # 2. Run Generation
# We call the existing entry point from cli.engine import run_generation
from main import run_generation
run_generation(bible_path, specific_run_id=run_id) run_generation(bible_path, specific_run_id=run_id)
utils.log("SYSTEM", "Job Complete.") utils.log("SYSTEM", "Job Complete.")
utils.update_progress(100) utils.update_progress(100)
status = "completed" status = "completed"
except Exception as e: except Exception as e:
utils.log("ERROR", f"Job Failed: {e}") utils.log("ERROR", f"Job Failed: {e}")
status = "failed" status = "failed"
# 3. Calculate Cost & Cleanup # 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}") run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
total_cost = 0.0 total_cost = 0.0
final_log_path = initial_log final_log_path = initial_log
if os.path.exists(run_dir): if os.path.exists(run_dir):
# Move our log file there
final_log_path = os.path.join(run_dir, "web_console.log") final_log_path = os.path.join(run_dir, "web_console.log")
if os.path.exists(initial_log): if os.path.exists(initial_log):
try: try:
os.rename(initial_log, final_log_path) os.rename(initial_log, final_log_path)
except OSError: except OSError:
# If rename fails (e.g. across filesystems), copy and delete
shutil.copy2(initial_log, final_log_path) shutil.copy2(initial_log, final_log_path)
os.remove(initial_log) 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): for item in os.listdir(run_dir):
item_path = os.path.join(run_dir, item) item_path = os.path.join(run_dir, item)
if os.path.isdir(item_path) and item.startswith("Book_"): if os.path.isdir(item_path) and item.startswith("Book_"):
@@ -204,53 +186,46 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
# 4. Update Database with Final Status # 4. Update Database with Final Status
try: try:
with sqlite3.connect(db_path, timeout=10) as conn: with sqlite3.connect(db_path, timeout=10) as conn:
conn.execute("UPDATE run SET status = ?, cost = ?, end_time = ?, log_file = ?, progress = 100 WHERE id = ?", conn.execute("UPDATE run SET status = ?, cost = ?, end_time = ?, log_file = ?, progress = 100 WHERE id = ?",
(status, total_cost, datetime.utcnow(), final_log_path, run_id)) (status, total_cost, datetime.utcnow(), final_log_path, run_id))
except Exception as e: except Exception as e:
print(f"Failed to update run status in DB: {e}") print(f"Failed to update run status in DB: {e}")
return {"run_id": run_id, "status": status, "cost": total_cost, "final_log": final_log_path} return {"run_id": run_id, "status": status, "cost": total_cost, "final_log": final_log_path}
@huey.task() @huey.task()
def regenerate_artifacts_task(run_id, project_path, feedback=None): 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") 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}") run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
log_file = os.path.join(run_dir, "web_console.log") 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): if not os.path.exists(run_dir):
log_file = os.path.join(project_path, f"system_log_{run_id}.txt") log_file = os.path.join(project_path, f"system_log_{run_id}.txt")
# Clear previous logs (File) to refresh the log window
try: try:
with open(log_file, 'w', encoding='utf-8') as f: with open(log_file, 'w', encoding='utf-8') as f:
f.write(f"[{datetime.utcnow().strftime('%H:%M:%S')}] --- REGENERATION STARTED ---\n") f.write(f"[{datetime.utcnow().strftime('%H:%M:%S')}] --- REGENERATION STARTED ---\n")
except: pass except: pass
utils.set_log_file(log_file)
utils.set_log_file(log_file)
utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m)) utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m))
try: try:
with sqlite3.connect(db_path) as conn: 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,)) conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,))
except: pass except: pass
utils.log("SYSTEM", "Starting Artifact Regeneration...") utils.log("SYSTEM", "Starting Artifact Regeneration...")
# 1. Setup Paths
# Detect Book Subfolder
book_dir = run_dir book_dir = run_dir
if os.path.exists(run_dir): if os.path.exists(run_dir):
subdirs = utils.get_sorted_book_folders(run_dir) subdirs = utils.get_sorted_book_folders(run_dir)
if subdirs: book_dir = os.path.join(run_dir, subdirs[0]) if subdirs: book_dir = os.path.join(run_dir, subdirs[0])
bible_path = os.path.join(project_path, "bible.json") bible_path = os.path.join(project_path, "bible.json")
if not os.path.exists(run_dir) or not os.path.exists(bible_path): if not os.path.exists(run_dir) or not os.path.exists(bible_path):
utils.log("ERROR", "Run directory or Bible not found.") utils.log("ERROR", "Run directory or Bible not found.")
try: try:
@@ -259,11 +234,10 @@ def regenerate_artifacts_task(run_id, project_path, feedback=None):
except: pass except: pass
return return
# 2. Load Data
bible = utils.load_json(bible_path) bible = utils.load_json(bible_path)
final_bp_path = os.path.join(book_dir, "final_blueprint.json") final_bp_path = os.path.join(book_dir, "final_blueprint.json")
ms_path = os.path.join(book_dir, "manuscript.json") ms_path = os.path.join(book_dir, "manuscript.json")
if not os.path.exists(final_bp_path) or not os.path.exists(ms_path): if not os.path.exists(final_bp_path) or not os.path.exists(ms_path):
utils.log("ERROR", f"Blueprint or Manuscript not found in {book_dir}") utils.log("ERROR", f"Blueprint or Manuscript not found in {book_dir}")
try: try:
@@ -271,21 +245,18 @@ def regenerate_artifacts_task(run_id, project_path, feedback=None):
conn.execute("UPDATE run SET status = 'failed' WHERE id = ?", (run_id,)) conn.execute("UPDATE run SET status = 'failed' WHERE id = ?", (run_id,))
except: pass except: pass
return return
bp = utils.load_json(final_bp_path) bp = utils.load_json(final_bp_path)
ms = utils.load_json(ms_path) ms = utils.load_json(ms_path)
# 3. Update Blueprint with new Metadata from Bible
meta = bible.get('project_metadata', {}) meta = bible.get('project_metadata', {})
if 'book_metadata' in bp: if 'book_metadata' in bp:
# Sync all core metadata
for k in ['author', 'genre', 'target_audience', 'style']: for k in ['author', 'genre', 'target_audience', 'style']:
if k in meta: if k in meta:
bp['book_metadata'][k] = meta[k] bp['book_metadata'][k] = meta[k]
if bp.get('series_metadata', {}).get('is_series'): if bp.get('series_metadata', {}).get('is_series'):
bp['series_metadata']['series_title'] = meta.get('title', bp['series_metadata'].get('series_title')) 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') b_num = bp['series_metadata'].get('book_number')
for b in bible.get('books', []): for b in bible.get('books', []):
if b.get('book_number') == b_num: if b.get('book_number') == b_num:
@@ -293,21 +264,20 @@ def regenerate_artifacts_task(run_id, project_path, feedback=None):
break break
else: else:
bp['book_metadata']['title'] = meta.get('title', bp['book_metadata'].get('title')) bp['book_metadata']['title'] = meta.get('title', bp['book_metadata'].get('title'))
with open(final_bp_path, 'w') as f: json.dump(bp, f, indent=2) with open(final_bp_path, 'w') as f: json.dump(bp, f, indent=2)
# 4. Regenerate
try: try:
ai.init_models() ai_setup.init_models()
tracking = None tracking = None
events_path = os.path.join(book_dir, "tracking_events.json") events_path = os.path.join(book_dir, "tracking_events.json")
if os.path.exists(events_path): 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"))} 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) marketing_cover.generate_cover(bp, book_dir, tracking, feedback=feedback)
export.compile_files(bp, ms, book_dir) exporter.compile_files(bp, ms, book_dir)
utils.log("SYSTEM", "Regeneration Complete.") utils.log("SYSTEM", "Regeneration Complete.")
final_status = 'completed' final_status = 'completed'
except Exception as e: except Exception as e:
@@ -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)) conn.execute("UPDATE run SET status = ? WHERE id = ?", (final_status, run_id))
except: pass except: pass
@huey.task() @huey.task()
def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instruction): def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instruction):
""" """
@@ -326,68 +297,63 @@ def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instructio
""" """
try: try:
run_dir = os.path.join(project_path, "runs", f"run_{run_id}") 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") log_file = os.path.join(run_dir, "web_console.log")
if not os.path.exists(log_file): if not os.path.exists(log_file):
log_file = os.path.join(project_path, f"system_log_{run_id}.txt") log_file = os.path.join(project_path, f"system_log_{run_id}.txt")
# Clear previous logs to refresh the log window
try: try:
with open(log_file, 'w', encoding='utf-8') as f: f.write("") with open(log_file, 'w', encoding='utf-8') as f: f.write("")
except: pass except: pass
utils.set_log_file(log_file) utils.set_log_file(log_file)
db_path = os.path.join(config.DATA_DIR, "bookapp.db") db_path = os.path.join(config.DATA_DIR, "bookapp.db")
utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m)) utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m))
try: try:
with sqlite3.connect(db_path) as conn: with sqlite3.connect(db_path) as conn:
conn.execute("DELETE FROM log_entry WHERE run_id = ?", (run_id,)) conn.execute("DELETE FROM log_entry WHERE run_id = ?", (run_id,))
conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,)) conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,))
except: pass except: pass
# ---------------------------------
book_path = os.path.join(run_dir, book_folder) book_path = os.path.join(run_dir, book_folder)
ms_path = os.path.join(book_path, "manuscript.json") ms_path = os.path.join(book_path, "manuscript.json")
bp_path = os.path.join(book_path, "final_blueprint.json") bp_path = os.path.join(book_path, "final_blueprint.json")
if not (os.path.exists(ms_path) and os.path.exists(bp_path)): if not (os.path.exists(ms_path) and os.path.exists(bp_path)):
utils.log("ERROR", f"Rewrite failed: files not found for run {run_id}/{book_folder}") utils.log("ERROR", f"Rewrite failed: files not found for run {run_id}/{book_folder}")
return False return False
ms = utils.load_json(ms_path) ms = utils.load_json(ms_path)
bp = utils.load_json(bp_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]: if result and result[0]:
new_text, summary = result new_text, summary = result
for ch in ms: for ch in ms:
if str(ch.get('num')) == str(chap_num): if str(ch.get('num')) == str(chap_num):
ch['content'] = new_text ch['content'] = new_text
break break
# Save the primary rewrite immediately
with open(ms_path, 'w') as f: json.dump(ms, f, indent=2) 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: if updated_ms:
ms = updated_ms ms = updated_ms
with open(ms_path, 'w') as f: json.dump(ms, f, indent=2) 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: try:
with sqlite3.connect(db_path) as conn: with sqlite3.connect(db_path) as conn:
conn.execute("UPDATE run SET status = 'completed' WHERE id = ?", (run_id,)) conn.execute("UPDATE run SET status = 'completed' WHERE id = ?", (run_id,))
except: pass except: pass
return True return True
# If result is False/None
try: try:
with sqlite3.connect(db_path) as conn: with sqlite3.connect(db_path) as conn:
conn.execute("UPDATE run SET status = 'completed' WHERE id = ?", (run_id,)) 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 except: pass
return False return False
@huey.task() @huey.task()
def refine_bible_task(project_path, instruction, source_type, selected_keys=None): def refine_bible_task(project_path, instruction, source_type, selected_keys=None):
""" """
@@ -411,38 +378,32 @@ def refine_bible_task(project_path, instruction, source_type, selected_keys=None
bible_path = os.path.join(project_path, "bible.json") bible_path = os.path.join(project_path, "bible.json")
draft_path = os.path.join(project_path, "bible_draft.json") draft_path = os.path.join(project_path, "bible_draft.json")
lock_path = os.path.join(project_path, ".refining") lock_path = os.path.join(project_path, ".refining")
with open(lock_path, 'w') as f: f.write("running") with open(lock_path, 'w') as f: f.write("running")
base_bible = utils.load_json(bible_path) base_bible = utils.load_json(bible_path)
if not base_bible: return False if not base_bible: return False
# If refining from draft, load it
if source_type == 'draft' and os.path.exists(draft_path): if source_type == 'draft' and os.path.exists(draft_path):
draft_bible = utils.load_json(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: 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: elif draft_bible:
# If no specific keys but source is draft, assume we refine the whole draft
base_bible = draft_bible base_bible = draft_bible
ai.init_models() ai_setup.init_models()
# Run AI Refinement new_bible = bible_tracker.refine_bible(base_bible, instruction, project_path)
new_bible = story.refine_bible(base_bible, instruction, project_path)
if new_bible: if new_bible:
# Save to draft file (Overwrite previous draft)
with open(draft_path, 'w') as f: json.dump(new_bible, f, indent=2) with open(draft_path, 'w') as f: json.dump(new_bible, f, indent=2)
return True return True
return False return False
except Exception as e: except Exception as e:
utils.log("ERROR", f"Bible refinement task failed: {e}") utils.log("ERROR", f"Bible refinement task failed: {e}")
return False return False
finally: finally:
if os.path.exists(lock_path): os.remove(lock_path) if os.path.exists(lock_path): os.remove(lock_path)