From f7099cc3e492e04e8aa58447df99aabf057178e6 Mon Sep 17 00:00:00 2001 From: Mike Wichers Date: Fri, 20 Feb 2026 22:20:53 -0500 Subject: [PATCH] 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 --- .claude/settings.local.json | 12 + Dockerfile | 6 +- __init__.py | 1 - ai/__init__.py | 0 ai/models.py | 71 + modules/ai.py => ai/setup.py | 239 +-- ai_blueprint.md | 312 ++++ cli/__init__.py | 0 main.py => cli/engine.py | 211 ++- wizard.py => cli/wizard.py | 341 ++--- core/__init__.py | 0 config.py => core/config.py | 16 +- {modules => core}/utils.py | 60 +- docker-compose.yml | 11 +- export/__init__.py | 0 modules/export.py => export/exporter.py | 28 +- marketing/__init__.py | 0 marketing/assets.py | 7 + marketing/blurb.py | 51 + modules/marketing.py => marketing/cover.py | 221 +-- marketing/fonts.py | 55 + modules/story.py | 1291 ---------------- modules/web_app.py | 1612 -------------------- story/__init__.py | 0 story/bible_tracker.py | 144 ++ story/editor.py | 399 +++++ story/planner.py | 265 ++++ story/style_persona.py | 180 +++ story/writer.py | 278 ++++ templates/admin_dashboard.html | 8 +- templates/admin_spend.html | 2 +- templates/admin_style.html | 4 +- templates/base.html | 2 +- templates/consistency_report.html | 2 +- templates/persona_edit.html | 4 +- templates/personas.html | 6 +- templates/project.html | 2 +- templates/read_book.html | 4 +- templates/run_details.html | 16 +- templates/system_status.html | 4 +- web/__init__.py | 0 web/app.py | 106 ++ modules/web_db.py => web/db.py | 18 +- web/helpers.py | 25 + {modules => web}/requirements_web.txt | 0 web/routes/__init__.py | 0 web/routes/admin.py | 226 +++ web/routes/auth.py | 57 + web/routes/persona.py | 135 ++ web/routes/project.py | 760 +++++++++ web/routes/run.py | 335 ++++ modules/web_tasks.py => web/tasks.py | 255 ++-- 52 files changed, 3984 insertions(+), 3798 deletions(-) create mode 100644 .claude/settings.local.json delete mode 100644 __init__.py create mode 100644 ai/__init__.py create mode 100644 ai/models.py rename modules/ai.py => ai/setup.py (57%) create mode 100644 ai_blueprint.md create mode 100644 cli/__init__.py rename main.py => cli/engine.py (77%) rename wizard.py => cli/wizard.py (85%) create mode 100644 core/__init__.py rename config.py => core/config.py (83%) rename {modules => core}/utils.py (84%) create mode 100644 export/__init__.py rename modules/export.py => export/exporter.py (89%) create mode 100644 marketing/__init__.py create mode 100644 marketing/assets.py create mode 100644 marketing/blurb.py rename modules/marketing.py => marketing/cover.py (68%) create mode 100644 marketing/fonts.py delete mode 100644 modules/story.py delete mode 100644 modules/web_app.py create mode 100644 story/__init__.py create mode 100644 story/bible_tracker.py create mode 100644 story/editor.py create mode 100644 story/planner.py create mode 100644 story/style_persona.py create mode 100644 story/writer.py create mode 100644 web/__init__.py create mode 100644 web/app.py rename modules/web_db.py => web/db.py (86%) create mode 100644 web/helpers.py rename {modules => web}/requirements_web.txt (100%) create mode 100644 web/routes/__init__.py create mode 100644 web/routes/admin.py create mode 100644 web/routes/auth.py create mode 100644 web/routes/persona.py create mode 100644 web/routes/project.py create mode 100644 web/routes/run.py rename modules/web_tasks.py => web/tasks.py (68%) diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..9cbb165 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(wc:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(grep:*)", + "Bash(__NEW_LINE_cd9d4130f0b9d34d__ echo \"=== Root-level files \\(non-hidden, non-dir\\) ===\")", + "Bash(ls:*)" + ] + } +} diff --git a/Dockerfile b/Dockerfile index 106d995..3858a4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,11 +14,11 @@ RUN apt-get update && apt-get install -y \ # Copy requirements files COPY requirements.txt . -COPY modules/requirements_web.txt ./modules/ +COPY web/requirements_web.txt ./web/ # Install dependencies RUN pip install --no-cache-dir -r requirements.txt -RUN pip install --no-cache-dir -r modules/requirements_web.txt +RUN pip install --no-cache-dir -r web/requirements_web.txt # Copy the rest of the application COPY . . @@ -28,4 +28,4 @@ ENV PYTHONPATH=/app EXPOSE 5000 HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/login')" || exit 1 -CMD ["python", "-m", "modules.web_app"] \ No newline at end of file +CMD ["python", "-m", "web.app"] \ No newline at end of file diff --git a/__init__.py b/__init__.py deleted file mode 100644 index 500fac0..0000000 --- a/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# BookApp Modules \ No newline at end of file diff --git a/ai/__init__.py b/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai/models.py b/ai/models.py new file mode 100644 index 0000000..0449728 --- /dev/null +++ b/ai/models.py @@ -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 diff --git a/modules/ai.py b/ai/setup.py similarity index 57% rename from modules/ai.py rename to ai/setup.py index 39973fe..ee5ba8a 100644 --- a/modules/ai.py +++ b/ai/setup.py @@ -3,94 +3,31 @@ import json import time import warnings import google.generativeai as genai -import config -from . import utils +from core import config, utils +from ai import models -# Suppress Vertex AI warnings -warnings.filterwarnings("ignore", category=UserWarning, module="vertexai") - -try: - import vertexai - from vertexai.preview.vision_models import ImageGenerationModel as VertexImageModel - HAS_VERTEX = True -except ImportError: - HAS_VERTEX = False - -try: - from google.auth.transport.requests import Request - from google.oauth2.credentials import Credentials - from google_auth_oauthlib.flow import InstalledAppFlow - HAS_OAUTH = True -except ImportError: - HAS_OAUTH = False - -model_logic = None -model_writer = None -model_artist = None -model_image = None -logic_model_name = "models/gemini-1.5-pro" -writer_model_name = "models/gemini-1.5-flash" -artist_model_name = "models/gemini-1.5-flash" -image_model_name = None -image_model_source = "None" - -class ResilientModel: - def __init__(self, name, safety_settings, role): - self.name = name - self.safety_settings = safety_settings - self.role = role - self.model = genai.GenerativeModel(name, safety_settings=safety_settings) - - def update(self, name): - self.name = name - self.model = genai.GenerativeModel(name, safety_settings=self.safety_settings) - - def generate_content(self, *args, **kwargs): - retries = 0 - max_retries = 3 - base_delay = 5 - - while True: - try: - return self.model.generate_content(*args, **kwargs) - except Exception as e: - err_str = str(e).lower() - is_retryable = "429" in err_str or "quota" in err_str or "500" in err_str or "503" in err_str or "504" in err_str or "deadline" in err_str or "internal error" in err_str - if is_retryable and retries < max_retries: - delay = base_delay * (2 ** retries) - utils.log("SYSTEM", f"⚠️ Quota error on {self.role} ({self.name}). Retrying in {delay}s...") - time.sleep(delay) - - # On first retry, attempt to re-optimize/rotate models - if retries == 0: - utils.log("SYSTEM", "Attempting to re-optimize models to find alternative...") - init_models(force=True) - # Note: init_models calls .update() on this instance - - retries += 1 - continue - raise e def get_optimal_model(base_type="pro"): try: - models = [m for m in genai.list_models() if 'generateContent' in m.supported_generation_methods] - candidates = [m.name for m in models if base_type in m.name] + available = [m for m in genai.list_models() if 'generateContent' in m.supported_generation_methods] + candidates = [m.name for m in available if base_type in m.name] if not candidates: return f"models/gemini-1.5-{base_type}" + def score(n): - # Prefer newer generations: 2.5 > 2.0 > 1.5 gen_bonus = 0 if "2.5" in n: gen_bonus = 300 elif "2.0" in n: gen_bonus = 200 elif "2." in n: gen_bonus = 150 - # Within a generation, prefer stable over experimental if "exp" in n or "beta" in n or "preview" in n: return gen_bonus + 0 if "latest" in n: return gen_bonus + 50 return gen_bonus + 100 + return sorted(candidates, key=score, reverse=True)[0] except Exception as e: utils.log("SYSTEM", f"⚠️ Error finding optimal model: {e}") return f"models/gemini-1.5-{base_type}" + def get_default_models(): return { "logic": {"model": "models/gemini-2.0-pro-exp", "reason": "Fallback: Gemini 2.0 Pro for complex reasoning and JSON adherence.", "estimated_cost": "$0.00/1M (Experimental)"}, @@ -99,27 +36,21 @@ def get_default_models(): "ranking": [] } + def select_best_models(force_refresh=False): - """ - Uses a safe bootstrapper model to analyze available models and pick the best ones. - Caches the result for 24 hours. - """ cache_path = os.path.join(config.DATA_DIR, "model_cache.json") cached_models = None - - # 1. Check Cache + if os.path.exists(cache_path): try: with open(cache_path, 'r') as f: cached = json.load(f) cached_models = cached.get('models', {}) - # Check if within 24 hours (86400 seconds) if not force_refresh and time.time() - cached.get('timestamp', 0) < 86400: - models = cached_models - # Validate format (must be dicts with reasons, not just strings) - if isinstance(models.get('logic'), dict) and 'reason' in models['logic']: + m = cached_models + if isinstance(m.get('logic'), dict) and 'reason' in m['logic']: utils.log("SYSTEM", "Using cached AI model selection (valid for 24h).") - return models + return m except Exception as e: utils.log("SYSTEM", f"Cache read failed: {e}. Refreshing models.") @@ -128,20 +59,20 @@ def select_best_models(force_refresh=False): all_models = list(genai.list_models()) raw_model_names = [m.name for m in all_models] utils.log("SYSTEM", f"Found {len(all_models)} raw models from Google API.") - - models = [m.name for m in all_models if 'generateContent' in m.supported_generation_methods and 'gemini' in m.name.lower()] - utils.log("SYSTEM", f"Identified {len(models)} compatible Gemini models: {models}") - + + compatible = [m.name for m in all_models if 'generateContent' in m.supported_generation_methods and 'gemini' in m.name.lower()] + utils.log("SYSTEM", f"Identified {len(compatible)} compatible Gemini models: {compatible}") + bootstrapper = get_optimal_model("flash") utils.log("SYSTEM", f"Bootstrapping model selection with: {bootstrapper}") - + model = genai.GenerativeModel(bootstrapper) prompt = f""" ROLE: AI Model Architect TASK: Select the optimal Gemini models for a book-writing application. Prefer newer Gemini 2.x models when available. AVAILABLE_MODELS: - {json.dumps(models)} + {json.dumps(compatible)} PRICING_CONTEXT (USD per 1M tokens, approximate): - Gemini 2.5 Pro/Flash: Best quality/speed; check current pricing. @@ -172,49 +103,47 @@ def select_best_models(force_refresh=False): "ranking": [ {{ "model": "string", "reason": "string", "estimated_cost": "string" }} ] }} """ - + try: response = model.generate_content(prompt) selection = json.loads(utils.clean_json(response.text)) except Exception as e: utils.log("SYSTEM", f"Model selection generation failed (Safety/Format): {e}") raise e - + if not os.path.exists(config.DATA_DIR): os.makedirs(config.DATA_DIR) with open(cache_path, 'w') as f: json.dump({ - "timestamp": int(time.time()), - "models": selection, - "available_at_time": models, + "timestamp": int(time.time()), + "models": selection, + "available_at_time": compatible, "raw_models": raw_model_names }, f, indent=2) return selection + except Exception as e: utils.log("SYSTEM", f"AI Model Selection failed: {e}.") - - # 3. Fallback to Stale Cache if available (Better than heuristics) - # Relaxed check: If we successfully loaded ANY JSON from the cache, use it. + if cached_models: utils.log("SYSTEM", "⚠️ Using stale cached models due to API failure.") return cached_models utils.log("SYSTEM", "Falling back to heuristics.") fallback = get_default_models() - - # Save fallback to cache if file doesn't exist OR if we couldn't load it (corrupt/None) - # This ensures we have a valid file on disk for the web UI to read. + try: with open(cache_path, 'w') as f: json.dump({"timestamp": int(time.time()), "models": fallback, "error": str(e)}, f, indent=2) except: pass return fallback + def init_models(force=False): - global model_logic, model_writer, model_artist, model_image, logic_model_name, writer_model_name, artist_model_name, image_model_name, image_model_source - if model_logic and not force: return + global_vars = models.__dict__ + if global_vars.get('model_logic') and not force: return + genai.configure(api_key=config.API_KEY) - - # Check cache to skip frequent validation + cache_path = os.path.join(config.DATA_DIR, "model_cache.json") skip_validation = False if not force and os.path.exists(cache_path): @@ -224,22 +153,19 @@ def init_models(force=False): except: pass if not skip_validation: - # Validate Gemini API Key utils.log("SYSTEM", "Validating credentials...") try: list(genai.list_models(page_size=1)) utils.log("SYSTEM", "✅ Gemini API Key is valid.") except Exception as e: - # Check if we have a cache file we can rely on before exiting if os.path.exists(cache_path): - utils.log("SYSTEM", f"⚠️ API check failed ({e}), but cache exists. Attempting to use cached models.") + utils.log("SYSTEM", f"⚠️ API check failed ({e}), but cache exists. Attempting to use cached models.") else: utils.log("SYSTEM", f"⚠️ API check failed ({e}). No cache found. Attempting to initialize with defaults.") utils.log("SYSTEM", "Selecting optimal models via AI...") selected_models = select_best_models(force_refresh=force) - - # Check for missing costs and force refresh if needed + if not force: missing_costs = False for role in ['logic', 'writer', 'artist']: @@ -257,57 +183,52 @@ def init_models(force=False): writer_name, writer_cost = get_model_details(selected_models['writer']) artist_name, artist_cost = get_model_details(selected_models['artist']) - logic_name = logic_model_name = logic_name if config.MODEL_LOGIC_HINT == "AUTO" else config.MODEL_LOGIC_HINT - writer_name = writer_model_name = writer_name if config.MODEL_WRITER_HINT == "AUTO" else config.MODEL_WRITER_HINT - artist_name = artist_model_name = artist_name if config.MODEL_ARTIST_HINT == "AUTO" else config.MODEL_ARTIST_HINT - + logic_name = logic_name if config.MODEL_LOGIC_HINT == "AUTO" else config.MODEL_LOGIC_HINT + writer_name = writer_name if config.MODEL_WRITER_HINT == "AUTO" else config.MODEL_WRITER_HINT + artist_name = artist_name if config.MODEL_ARTIST_HINT == "AUTO" else config.MODEL_ARTIST_HINT + + models.logic_model_name = logic_name + models.writer_model_name = writer_name + models.artist_model_name = artist_name + utils.log("SYSTEM", f"Models: Logic={logic_name} ({logic_cost}) | Writer={writer_name} ({writer_cost}) | Artist={artist_name}") - # Update pricing in utils utils.update_pricing(logic_name, logic_cost) utils.update_pricing(writer_name, writer_cost) utils.update_pricing(artist_name, artist_cost) - - # Initialize or Update Resilient Models - if model_logic is None: - model_logic = ResilientModel(logic_name, utils.SAFETY_SETTINGS, "Logic") - model_writer = ResilientModel(writer_name, utils.SAFETY_SETTINGS, "Writer") - model_artist = ResilientModel(artist_name, utils.SAFETY_SETTINGS, "Artist") + + if models.model_logic is None: + models.model_logic = models.ResilientModel(logic_name, utils.SAFETY_SETTINGS, "Logic") + models.model_writer = models.ResilientModel(writer_name, utils.SAFETY_SETTINGS, "Writer") + models.model_artist = models.ResilientModel(artist_name, utils.SAFETY_SETTINGS, "Artist") else: - # If models already exist (re-init), update them in place - model_logic.update(logic_name) - model_writer.update(writer_name) - model_artist.update(artist_name) - - # Initialize Image Model - model_image = None - image_model_name = None - image_model_source = "None" + models.model_logic.update(logic_name) + models.model_writer.update(writer_name) + models.model_artist.update(artist_name) + + models.model_image = None + models.image_model_name = None + models.image_model_source = "None" hint = config.MODEL_IMAGE_HINT if hasattr(config, 'MODEL_IMAGE_HINT') else "AUTO" if hasattr(genai, 'ImageGenerationModel'): - # Candidate image models in preference order - if hint and hint != "AUTO": - candidates = [hint] - else: - candidates = ["imagen-3.0-generate-001", "imagen-3.0-fast-generate-001"] + candidates = [hint] if hint and hint != "AUTO" else ["imagen-3.0-generate-001", "imagen-3.0-fast-generate-001"] for candidate in candidates: try: - model_image = genai.ImageGenerationModel(candidate) - image_model_name = candidate - image_model_source = "Gemini API" + models.model_image = genai.ImageGenerationModel(candidate) + models.image_model_name = candidate + models.image_model_source = "Gemini API" utils.log("SYSTEM", f"✅ Image model: {candidate} (Gemini API)") break except Exception: continue - # Auto-detect GCP Project from credentials if not set (Fix for Image Model) - if HAS_VERTEX and not config.GCP_PROJECT and config.GOOGLE_CREDS and os.path.exists(config.GOOGLE_CREDS): + # Auto-detect GCP Project + if models.HAS_VERTEX and not config.GCP_PROJECT and config.GOOGLE_CREDS and os.path.exists(config.GOOGLE_CREDS): try: with open(config.GOOGLE_CREDS, 'r') as f: cdata = json.load(f) - # Check common OAuth structures for k in ['installed', 'web']: if k in cdata and 'project_id' in cdata[k]: config.GCP_PROJECT = cdata[k]['project_id'] @@ -315,58 +236,54 @@ def init_models(force=False): break except: pass - if HAS_VERTEX and config.GCP_PROJECT: + if models.HAS_VERTEX and config.GCP_PROJECT: creds = None - # Handle OAuth Client ID (credentials.json) if provided instead of Service Account - if HAS_OAUTH: - gac = config.GOOGLE_CREDS # Use persistent config, not volatile env var + if models.HAS_OAUTH: + gac = config.GOOGLE_CREDS if gac and os.path.exists(gac): try: with open(gac, 'r') as f: data = json.load(f) if 'installed' in data or 'web' in data: - # It's an OAuth Client ID. Unset env var to avoid library crash. if "GOOGLE_APPLICATION_CREDENTIALS" in os.environ: del os.environ["GOOGLE_APPLICATION_CREDENTIALS"] - + token_path = os.path.join(os.path.dirname(os.path.abspath(gac)), 'token.json') SCOPES = ['https://www.googleapis.com/auth/cloud-platform'] - + if os.path.exists(token_path): - creds = Credentials.from_authorized_user_file(token_path, SCOPES) - + creds = models.Credentials.from_authorized_user_file(token_path, SCOPES) + if not creds or not creds.valid: if creds and creds.expired and creds.refresh_token: try: - creds.refresh(Request()) + creds.refresh(models.Request()) except Exception: utils.log("SYSTEM", "Token refresh failed. Re-authenticating...") - flow = InstalledAppFlow.from_client_secrets_file(gac, SCOPES) + flow = models.InstalledAppFlow.from_client_secrets_file(gac, SCOPES) creds = flow.run_local_server(port=0) else: utils.log("SYSTEM", "OAuth Client ID detected. Launching browser to authenticate...") - flow = InstalledAppFlow.from_client_secrets_file(gac, SCOPES) + flow = models.InstalledAppFlow.from_client_secrets_file(gac, SCOPES) creds = flow.run_local_server(port=0) with open(token_path, 'w') as token: token.write(creds.to_json()) - + utils.log("SYSTEM", "✅ Authenticated via OAuth Client ID.") except Exception as e: utils.log("SYSTEM", f"⚠️ OAuth check failed: {e}") - vertexai.init(project=config.GCP_PROJECT, location=config.GCP_LOCATION, credentials=creds) + import vertexai as _vertexai + _vertexai.init(project=config.GCP_PROJECT, location=config.GCP_LOCATION, credentials=creds) utils.log("SYSTEM", f"✅ Vertex AI initialized (Project: {config.GCP_PROJECT})") - - # Override with Vertex Image Model if available - vertex_candidates = ["imagen-3.0-generate-001", "imagen-3.0-fast-generate-001"] - if hint and hint != "AUTO": - vertex_candidates = [hint] + + vertex_candidates = [hint] if hint and hint != "AUTO" else ["imagen-3.0-generate-001", "imagen-3.0-fast-generate-001"] for candidate in vertex_candidates: try: - model_image = VertexImageModel.from_pretrained(candidate) - image_model_name = candidate - image_model_source = "Vertex AI" + models.model_image = models.VertexImageModel.from_pretrained(candidate) + models.image_model_name = candidate + models.image_model_source = "Vertex AI" utils.log("SYSTEM", f"✅ Image model: {candidate} (Vertex AI)") break except Exception: continue - utils.log("SYSTEM", f"Image Generation Provider: {image_model_source} ({image_model_name or 'unavailable'})") \ No newline at end of file + utils.log("SYSTEM", f"Image Generation Provider: {models.image_model_source} ({models.image_model_name or 'unavailable'})") diff --git a/ai_blueprint.md b/ai_blueprint.md new file mode 100644 index 0000000..f7dda9d --- /dev/null +++ b/ai_blueprint.md @@ -0,0 +1,312 @@ +# AI Blueprint: Modularization Plan + +This blueprint details the strategy to break down the monolithic files (`main.py`, `wizard.py`, `modules/story.py`, `modules/web_app.py`, `modules/marketing.py`, `modules/web_tasks.py`, `modules/ai.py`) into a small-file, Single Responsibility architecture. + +## Proposed Folder Structure + +``` +c:/Users/thethreemagi/OneDrive/Gemini/BookApp/ +├── core/ +│ ├── config.py +│ └── utils.py +├── ai/ +│ ├── setup.py +│ └── models.py +├── story/ +│ ├── planner.py +│ ├── writer.py +│ ├── editor.py +│ ├── style_persona.py +│ └── bible_tracker.py +├── marketing/ +│ ├── blurb.py +│ ├── cover.py +│ ├── fonts.py +│ └── assets.py +├── export/ +│ └── exporter.py +├── web/ +│ ├── app.py +│ ├── db.py +│ ├── tasks.py +│ └── routes/ +│ ├── auth.py +│ ├── project.py +│ ├── run.py +│ ├── admin.py +│ └── persona.py +├── cli/ +│ ├── engine.py +│ └── wizard.py +``` + +## Step-by-Step Migration Details + +### 1. `core/` Module +**New File: `core/config.py`** +- Moves `config.py` unchanged. + +**New File: `core/utils.py`** +- Moves all functions from `modules/utils.py`. +- **Exact Imports:** + ```python + import os, json, datetime, time, threading, re + from core import config + ``` + +### 2. `ai/` Module (Extracting from `modules/ai.py`) +**New File: `ai/models.py`** +- Extract `ResilientModel` class, and global model variables (`model_logic`, `model_writer`, etc.). +- **Exact Imports:** + ```python + import google.generativeai as genai + from core import utils + ``` + +**New File: `ai/setup.py`** +- Extract `init_models`, `select_best_models`, `get_optimal_model`, `get_default_models`. +- **Exact Imports:** + ```python + import os, json, time, warnings + import google.generativeai as genai + from core import config, utils + from ai import models + ``` + +### 3. `story/` Module (Extracting from `modules/story.py`) +**New File: `story/planner.py`** +- Extract `enrich`, `plan_structure`, `expand`, `create_chapter_plan`. +- **Exact Imports:** + ```python + import json, random + from core import utils + from ai import models as ai_models + from story.bible_tracker import filter_characters + ``` + +**New File: `story/writer.py`** +- Extract `write_chapter`. +- **Exact Imports:** + ```python + import json, os + from core import config, utils + from ai import models as ai_models + from story.style_persona import get_style_guidelines + from story.editor import evaluate_chapter_quality + ``` + +**New File: `story/editor.py`** +- Extract `evaluate_chapter_quality`, `check_pacing`, `analyze_consistency`, `rewrite_chapter_content`, `check_and_propagate`. +- **Exact Imports:** + ```python + import json, os + from core import config, utils + from ai import models as ai_models + from story.style_persona import get_style_guidelines + ``` + +**New File: `story/style_persona.py`** +- Extract `get_style_guidelines`, `refresh_style_guidelines`, `create_initial_persona`, `refine_persona`, `update_persona_sample`. +- **Exact Imports:** + ```python + import json, os, time + from core import config, utils + from ai import models as ai_models + ``` + +**New File: `story/bible_tracker.py`** +- Extract `merge_selected_changes`, `filter_characters`, `update_tracking`, `harvest_metadata`, `refine_bible`. +- **Exact Imports:** + ```python + import json + from core import utils + from ai import models as ai_models + ``` + +### 4. `marketing/` Module (Extracting from `modules/marketing.py`) +**New File: `marketing/fonts.py`** +- Extract `download_font`. +- **Exact Imports:** + ```python + import os, requests + from core import config, utils + ``` + +**New File: `marketing/cover.py`** +- Extract `generate_cover`, `evaluate_image_quality`. +- **Exact Imports:** + ```python + import os, json, shutil, textwrap, subprocess, sys + from core import config, utils + from ai import models as ai_models + from marketing.fonts import download_font + from rich.prompt import Confirm + try: + from PIL import Image, ImageDraw, ImageFont + HAS_PIL = True + except ImportError: + HAS_PIL = False + ``` + +**New File: `marketing/blurb.py`** +- Extract `generate_blurb`. +- **Exact Imports:** + ```python + import os + from core import utils + from ai import models as ai_models + ``` + +**New File: `marketing/assets.py`** +- Extract `create_marketing_assets`. +- **Exact Imports:** + ```python + from marketing.blurb import generate_blurb + from marketing.cover import generate_cover + ``` + +### 5. `export/` Module (Extracting from `modules/export.py`) +**New File: `export/exporter.py`** +- Extract `create_readme`, `compile_files`. +- **Exact Imports:** + ```python + import os, markdown + from docx import Document + from ebooklib import epub + from core import utils + ``` + +### 6. `web/` Module (Extracting from `modules/web_app.py` and `modules/web_tasks.py`) +**New File: `web/db.py`** +- Extract models `User`, `Project`, `Run`, `LogEntry` from `modules/web_db.py`. +- **Exact Imports:** + ```python + from flask_sqlalchemy import SQLAlchemy + from flask_login import UserMixin + from datetime import datetime + db = SQLAlchemy() + ``` + +**New File: `web/tasks.py`** +- Extract Huey configuration and tasks `generate_book_task`, `regenerate_artifacts_task`, `rewrite_chapter_task`, `refine_bible_task`. +- **Exact Imports:** + ```python + import os, json, time, sqlite3, shutil + from datetime import datetime + from huey import SqliteHuey + from core import config, utils + from ai import setup as ai_setup + from story import planner, writer, editor, style_persona, bible_tracker + from marketing import cover, assets + from export import exporter + from web.db import db, Run, User, Project + ``` + +**New File: `web/routes/auth.py`** +- Extract `/login`, `/register`, `/logout`. +- **Exact Imports:** + ```python + from flask import Blueprint, render_template, request, redirect, url_for, flash + from flask_login import login_user, login_required, logout_user, current_user + from werkzeug.security import generate_password_hash, check_password_hash + from web.db import db, User + ``` + +**New File: `web/routes/project.py`** +- Extract `/`, `/project/setup`, `/project/setup/refine`, `/project/create`, `/project/import`, `/project/`, `/project//update`, `/project//clone`, `/project//add_book`, `/project//book//update`, `/project//delete_book/`, `/project//import_characters`, `/project//set_persona`. +- **Exact Imports:** + ```python + import os, json + from datetime import datetime + from flask import Blueprint, render_template, request, redirect, url_for, flash + from flask_login import login_required, current_user + from core import config, utils + from ai import setup as ai_setup, models as ai_models + from web.db import db, Project, Run + from story import planner, bible_tracker + ``` + +**New File: `web/routes/run.py`** +- Extract `/project//run`, `/run/`, `/run//status`, `/run//stop`, `/run//restart`, `/project//regenerate_artifacts`, `/project//revise_book/`, `/project//read/`, `/project//save_chapter`, `/project//check_consistency/`, `/project//sync_book/`, `/project//rewrite_chapter`, `/project//download`, `/task_status/`. +- **Exact Imports:** + ```python + import os, json, markdown + from flask import Blueprint, render_template, request, redirect, url_for, flash, send_from_directory, session + from flask_login import login_required, current_user + from web.db import db, Project, Run, LogEntry + from web.tasks import huey, generate_book_task, regenerate_artifacts_task, rewrite_chapter_task + from core import config, utils + from story import editor, bible_tracker, style_persona + from export import exporter + ``` + +**New File: `web/routes/persona.py`** +- Extract `/personas`, `/persona/new`, `/persona/`, `/persona/save`, `/persona/delete/`, `/persona/analyze`. +- **Exact Imports:** + ```python + import os, json + from flask import Blueprint, render_template, request, redirect, url_for, flash + from flask_login import login_required + from core import config, utils + from ai import setup as ai_setup, models as ai_models + ``` + +**New File: `web/routes/admin.py`** +- Extract `/admin`, `/admin/user//delete`, `/admin/project//delete`, `/admin/reset`, `/admin/spend`, `/admin/style`, `/admin/impersonate/`, `/admin/stop_impersonate`, `/debug/routes`, `/system/optimize_models`, `/system/status`. +- **Exact Imports:** + ```python + import os, json, shutil + from datetime import datetime, timedelta + from flask import Blueprint, render_template, request, redirect, url_for, flash, session + from flask_login import login_required, current_user, login_user + from sqlalchemy import func + from web.db import db, User, Project, Run + from core import config, utils + from story import style_persona + from ai import setup as ai_setup, models as ai_models + ``` + +**New File: `web/app.py`** +- Flask initialization, registering blueprints, and the `__main__` entry point. +- **Exact Imports:** + ```python + import os + from flask import Flask + from flask_login import LoginManager + from core import config + from web.db import db, User + ``` + +### 7. `cli/` Module +**New File: `cli/engine.py`** +- Extract `process_book` and `run_generation` from `main.py`. +- **Exact Imports:** + ```python + import json, os, time, sys, shutil + from rich.prompt import Confirm + from core import config, utils + from ai import setup as ai_setup + from story import planner, writer, editor, style_persona, bible_tracker + from marketing import cover, assets + from export import exporter + ``` + +**New File: `cli/wizard.py`** +- Move `wizard.py` here and update imports. +- **Exact Imports:** + ```python + import os, sys, json + from rich.console import Console + from rich.panel import Panel + from rich.prompt import Prompt, IntPrompt, Confirm + from rich.table import Table + from flask import Flask + from core import config, utils + from ai import setup as ai_setup, models as ai_models + from web.db import db, User, Project + ``` + +## HTML Template Changes +The `templates/` folder contains HTML files. Ensure any `url_for()` calls reference the correct Blueprint names (e.g., `url_for('auth.login')` instead of `url_for('login')`). + +Awaiting approval to begin creating files and making structural changes. \ No newline at end of file diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/cli/engine.py similarity index 77% rename from main.py rename to cli/engine.py index e5b9467..3d3806c 100644 --- a/main.py +++ b/cli/engine.py @@ -1,21 +1,29 @@ -import json, os, time, sys, shutil -import config +import json +import os +import time +import sys +import shutil from rich.prompt import Confirm -from modules import ai, story, marketing, export, utils +from core import config, utils +from ai import models as ai_models +from ai import setup as ai_setup +from story import planner, writer as story_writer, editor as story_editor +from story import style_persona, bible_tracker +from marketing import assets as marketing_assets +from export import exporter + def process_book(bp, folder, context="", resume=False, interactive=False): # Create lock file to indicate active processing lock_path = os.path.join(folder, ".in_progress") with open(lock_path, "w") as f: f.write("running") - + total_start = time.time() - + try: # 1. Check completion if resume and os.path.exists(os.path.join(folder, "final_blueprint.json")): utils.log("SYSTEM", f"Book in {folder} already finished. Skipping.") - - # Clean up zombie lock file if it exists if os.path.exists(lock_path): os.remove(lock_path) return @@ -26,7 +34,6 @@ def process_book(bp, folder, context="", resume=False, interactive=False): if resume and os.path.exists(bp_path): utils.log("RESUME", "Loading existing blueprint...") saved_bp = utils.load_json(bp_path) - # Merge latest metadata from Bible (passed in bp) into saved blueprint if saved_bp: if 'book_metadata' in bp and 'book_metadata' in saved_bp: for k in ['title', 'author', 'genre', 'target_audience', 'style', 'author_bio', 'author_details']: @@ -37,16 +44,16 @@ def process_book(bp, folder, context="", resume=False, interactive=False): bp = saved_bp with open(bp_path, "w") as f: json.dump(bp, f, indent=2) else: - bp = story.enrich(bp, folder, context) + bp = planner.enrich(bp, folder, context) with open(bp_path, "w") as f: json.dump(bp, f, indent=2) - + # Ensure Persona Exists (Auto-create if missing) if 'author_details' not in bp['book_metadata'] or not bp['book_metadata']['author_details']: - bp['book_metadata']['author_details'] = story.create_initial_persona(bp, folder) + bp['book_metadata']['author_details'] = style_persona.create_initial_persona(bp, folder) with open(bp_path, "w") as f: json.dump(bp, f, indent=2) utils.log("TIMING", f"Blueprint Phase: {time.time() - t_step:.1f}s") - + # 3. Events (Plan & Expand) events_path = os.path.join(folder, "events.json") t_step = time.time() @@ -55,15 +62,15 @@ def process_book(bp, folder, context="", resume=False, interactive=False): utils.log("RESUME", "Loading existing events...") events = utils.load_json(events_path) else: - events = story.plan_structure(bp, folder) - depth = bp['length_settings']['depth'] + events = planner.plan_structure(bp, folder) + depth = bp['length_settings']['depth'] target_chaps = bp['length_settings']['chapters'] - for d in range(1, depth+1): - events = story.expand(events, d, target_chaps, bp, folder) + for d in range(1, depth+1): + events = planner.expand(events, d, target_chaps, bp, folder) time.sleep(1) with open(events_path, "w") as f: json.dump(events, f, indent=2) utils.log("TIMING", f"Structure & Expansion: {time.time() - t_step:.1f}s") - + # 4. Chapter Plan chapters_path = os.path.join(folder, "chapters.json") 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...") chapters = utils.load_json(chapters_path) else: - chapters = story.create_chapter_plan(events, bp, folder) + chapters = planner.create_chapter_plan(events, bp, folder) with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2) utils.log("TIMING", f"Chapter Planning: {time.time() - t_step:.1f}s") - + # 5. Writing Loop ms_path = os.path.join(folder, "manuscript.json") 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 [] - + # Load Tracking events_track_path = os.path.join(folder, "tracking_events.json") chars_track_path = os.path.join(folder, "tracking_characters.json") warn_track_path = os.path.join(folder, "tracking_warnings.json") - + tracking = {"events": [], "characters": {}, "content_warnings": []} if resume: 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) if os.path.exists(warn_track_path): tracking['content_warnings'] = utils.load_json(warn_track_path) - + summary = "The story begins." if ms: - # Efficient rebuild: first chapter (setup) + last 4 (recent events) avoids huge prompts utils.log("RESUME", f"Rebuilding story context from {len(ms)} existing chapters...") try: selected = ms[:1] + ms[-4:] if len(ms) > 5 else ms combined_text = "\n".join([f"Chapter {c['num']}: {c['content'][:3000]}" for c in selected]) - resp_sum = ai.model_writer.generate_content(f""" + resp_sum = ai_models.model_writer.generate_content(f""" ROLE: Series Historian TASK: Create a cumulative 'Story So Far' summary. INPUT_TEXT: @@ -110,7 +116,7 @@ def process_book(bp, folder, context="", resume=False, interactive=False): INSTRUCTIONS: Use dense, factual bullet points. Focus on character meetings, relationships, and known information. OUTPUT: Summary text. """) - utils.log_usage(folder, ai.model_writer.name, resp_sum.usage_metadata) + utils.log_usage(folder, ai_models.model_writer.name, resp_sum.usage_metadata) summary = resp_sum.text except: summary = "The story continues." @@ -122,39 +128,36 @@ def process_book(bp, folder, context="", resume=False, interactive=False): while i < len(chapters): ch_start = time.time() ch = chapters[i] - + # Check for stop signal from Web UI run_dir = os.path.dirname(folder) if os.path.exists(os.path.join(run_dir, ".stop")): - utils.log("SYSTEM", "🛑 Stop signal detected. Aborting generation.") + utils.log("SYSTEM", "Stop signal detected. Aborting generation.") break - + # Robust Resume: Check if this specific chapter number is already in the manuscript - # (Handles cases where plan changed or ms is out of sync with index) if any(str(c.get('num')) == str(ch['chapter_number']) for c in ms): i += 1 continue - # Progress Banner — update bar and log chapter header before writing begins + # Progress Banner utils.update_progress(15 + int((i / len(chapters)) * 75)) utils.log_banner("WRITER", f"Chapter {ch['chapter_number']}/{len(chapters)}: {ch['title']}") - # Pass previous chapter content for continuity if available prev_content = ms[-1]['content'] if ms else None - + while True: try: - # Cap summary to most-recent 8000 chars; pass next chapter title as hook hint summary_ctx = summary[-8000:] if len(summary) > 8000 else summary next_hint = chapters[i+1]['title'] if i + 1 < len(chapters) else "" - txt = story.write_chapter(ch, bp, folder, summary_ctx, tracking, prev_content, next_chapter_hint=next_hint) + txt = story_writer.write_chapter(ch, bp, folder, summary_ctx, tracking, prev_content, next_chapter_hint=next_hint) except Exception as e: utils.log("SYSTEM", f"Chapter generation failed: {e}") if interactive: if Confirm.ask("Generation failed (quality/error). Retry?", default=True): continue raise e - + if interactive: print(f"\n--- Chapter {ch['chapter_number']} Preview ---\n{txt[:800]}...\n-------------------------------") 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...") else: break - - # Refine Persona to match the actual output (every 5 chapters to save API calls) + + # Refine Persona to match the actual output (every 5 chapters) if (i == 0 or i % 5 == 0) and txt: - bp['book_metadata']['author_details'] = story.refine_persona(bp, txt, folder) + bp['book_metadata']['author_details'] = style_persona.refine_persona(bp, txt, folder) with open(bp_path, "w") as f: json.dump(bp, f, indent=2) - # Look ahead for context to ensure relevant details are captured + # Look ahead for context next_info = "" if i + 1 < len(chapters): next_ch = chapters[i+1] 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""" ROLE: Series Historian TASK: Update the 'Story So Far' summary to include the events of this new chapter. - + INPUT_DATA: - CURRENT_SUMMARY: {summary} - NEW_CHAPTER_TEXT: {txt} - UPCOMING_CONTEXT_HINT: {next_info} - + INSTRUCTIONS: 1. STYLE: Dense, factual, chronological bullet points. Avoid narrative prose. 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. 4. RELEVANCE: Ensure details needed for the UPCOMING CONTEXT are preserved. - + OUTPUT: Updated summary text. """ - resp_sum = ai.model_writer.generate_content(update_prompt) - utils.log_usage(folder, ai.model_writer.name, resp_sum.usage_metadata) + resp_sum = ai_models.model_writer.generate_content(update_prompt) + utils.log_usage(folder, ai_models.model_writer.name, resp_sum.usage_metadata) summary = resp_sum.text except: - try: - resp_fallback = ai.model_writer.generate_content(f"ROLE: Summarizer\nTASK: Summarize plot points.\nTEXT: {txt}\nOUTPUT: Bullet points.") - utils.log_usage(folder, ai.model_writer.name, resp_fallback.usage_metadata) + try: + resp_fallback = ai_models.model_writer.generate_content(f"ROLE: Summarizer\nTASK: Summarize plot points.\nTEXT: {txt}\nOUTPUT: Bullet points.") + utils.log_usage(folder, ai_models.model_writer.name, resp_fallback.usage_metadata) summary += f"\n\nChapter {ch['chapter_number']}: " + resp_fallback.text except: summary += f"\n\nChapter {ch['chapter_number']}: [Content processed]" - + 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) - + # Update Tracking - tracking = story.update_tracking(folder, ch['chapter_number'], txt, tracking) + tracking = bible_tracker.update_tracking(folder, ch['chapter_number'], txt, tracking) with open(events_track_path, "w") as f: json.dump(tracking['events'], f, indent=2) with open(chars_track_path, "w") as f: json.dump(tracking['characters'], f, indent=2) with open(warn_track_path, "w") as f: json.dump(tracking.get('content_warnings', []), f, indent=2) - - # --- DYNAMIC PACING CHECK (every other chapter to halve API overhead) --- + + # Dynamic Pacing Check (every other chapter) remaining = chapters[i+1:] if remaining and len(remaining) >= 2 and i % 2 == 1: - pacing = story.check_pacing(bp, summary, txt, ch, remaining, folder) + pacing = story_editor.check_pacing(bp, summary, txt, ch, remaining, folder) if pacing and pacing.get('status') == 'add_bridge': new_data = pacing.get('new_chapter', {}) - # Estimate bridge chapter length from current plan average (not hardcoded) if chapters: avg_words = int(sum(c.get('estimated_words', 1500) for c in chapters) / len(chapters)) else: @@ -235,19 +237,15 @@ def process_book(bp, folder, context="", resume=False, interactive=False): "beats": new_data.get('beats', []) } chapters.insert(i+1, new_ch) - # Renumber subsequent chapters for k in range(i+1, len(chapters)): chapters[k]['chapter_number'] = k + 1 - with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2) - utils.log("ARCHITECT", f" -> ⚠️ Pacing Intervention: Added bridge chapter '{new_ch['title']}' to fix rushing.") - + utils.log("ARCHITECT", f" -> Pacing Intervention: Added bridge chapter '{new_ch['title']}' to fix rushing.") + elif pacing and pacing.get('status') == 'cut_next': removed = chapters.pop(i+1) - # Renumber subsequent chapters for k in range(i+1, len(chapters)): chapters[k]['chapter_number'] = k + 1 - with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2) - utils.log("ARCHITECT", f" -> ⚠️ Pacing Intervention: Removed redundant chapter '{removed['title']}'.") + utils.log("ARCHITECT", f" -> Pacing Intervention: Removed redundant chapter '{removed['title']}'.") elif pacing: utils.log("ARCHITECT", f" -> Pacing OK. {pacing.get('reason', '')[:100]}") @@ -259,66 +257,60 @@ def process_book(bp, folder, context="", resume=False, interactive=False): session_time += duration avg_time = session_time / session_chapters eta = avg_time * (len(chapters) - (i + 1)) - - # Calculate Progress (15% to 90%) + prog = 15 + int((i / len(chapters)) * 75) utils.update_progress(prog) - + 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"Writing Phase: {time.time() - t_step:.1f}s") # Harvest t_step = time.time() utils.update_progress(92) - bp = story.harvest_metadata(bp, folder, ms) + bp = bible_tracker.harvest_metadata(bp, folder, ms) with open(os.path.join(folder, "final_blueprint.json"), "w") as f: json.dump(bp, f, indent=2) - + # Create Assets utils.update_progress(95) - marketing.create_marketing_assets(bp, folder, tracking, interactive=interactive) - + marketing_assets.create_marketing_assets(bp, folder, tracking, interactive=interactive) + # Update Persona - story.update_persona_sample(bp, folder) - + style_persona.update_persona_sample(bp, folder) + utils.update_progress(98) - export.compile_files(bp, ms, folder) + exporter.compile_files(bp, ms, folder) utils.log("TIMING", f"Post-Processing: {time.time() - t_step:.1f}s") utils.log("SYSTEM", f"Book Finished. Total Time: {time.time() - total_start:.1f}s") - + finally: - # Remove lock file on success or failure if os.path.exists(lock_path): os.remove(lock_path) -# --- 6. ENTRY POINT --- + def run_generation(target=None, specific_run_id=None, interactive=False): - ai.init_models() - + ai_setup.init_models() + if not target: target = config.DEFAULT_BLUEPRINT data = utils.load_json(target) - + if not data: utils.log("SYSTEM", f"Could not load {target}") return - # --- BIBLE FORMAT --- utils.log("SYSTEM", "Starting Series Generation...") - - # Determine Run Directory: projects/{Project}/runs/run_X + project_dir = os.path.dirname(os.path.abspath(target)) runs_base = os.path.join(project_dir, "runs") - + run_dir = None resume_mode = False - + if specific_run_id: - # WEB/WORKER MODE: Non-interactive, specific ID run_dir = os.path.join(runs_base, f"run_{specific_run_id}") if not os.path.exists(run_dir): os.makedirs(run_dir) - resume_mode = True # Always try to resume if files exist in this specific run + resume_mode = True else: - # CLI MODE: Interactive checks latest_run = utils.get_latest_run_folder(runs_base) if latest_run: has_lock = False @@ -326,7 +318,7 @@ def run_generation(target=None, specific_run_id=None, interactive=False): if ".in_progress" in files: has_lock = True break - + if has_lock: if Confirm.ask(f"Found incomplete run '{os.path.basename(latest_run)}'. Resume generation?", default=True): run_dir = latest_run @@ -335,21 +327,19 @@ def run_generation(target=None, specific_run_id=None, interactive=False): shutil.rmtree(latest_run) os.makedirs(latest_run) run_dir = latest_run - + if not run_dir: run_dir = utils.get_run_folder(runs_base) utils.log("SYSTEM", f"Run Directory: {run_dir}") - + previous_context = "" - + for i, book in enumerate(data['books']): utils.log("SERIES", f"Processing Book {book.get('book_number')}: {book.get('title')}") - - # Check for stop signal at book level + if os.path.exists(os.path.join(run_dir, ".stop")): - utils.log("SYSTEM", "🛑 Stop signal detected. Aborting series generation.") + utils.log("SYSTEM", "Stop signal detected. Aborting series generation.") break - - # Adapter: Bible -> Blueprint + meta = data['project_metadata'] bp = { "book_metadata": { @@ -373,35 +363,27 @@ def run_generation(target=None, specific_run_id=None, interactive=False): "total_books": len(data['books']) } } - - # Create Book Subfolder + safe_title = utils.sanitize_filename(book.get('title', f"Book_{i+1}")) book_folder = os.path.join(run_dir, f"Book_{book.get('book_number', i+1)}_{safe_title}") os.makedirs(book_folder, exist_ok=True) - - # Process + process_book(bp, book_folder, context=previous_context, resume=resume_mode, interactive=interactive) - - # Update Context for next book + final_bp_path = os.path.join(book_folder, "final_blueprint.json") if os.path.exists(final_bp_path): final_bp = utils.load_json(final_bp_path) - - # --- Update World Bible with new characters --- - # This ensures future books know about characters invented in this book + new_chars = final_bp.get('characters', []) - - # RELOAD BIBLE to avoid race conditions (User might have edited it in UI) + if os.path.exists(target): current_bible = utils.load_json(target) - - # 1. Merge New Characters + existing_names = {c['name'].lower() for c in current_bible.get('characters', [])} for char in new_chars: if char['name'].lower() not in existing_names: current_bible['characters'].append(char) - - # 2. Sync Generated Book Metadata (Title, Beats) back to Bible + for b in current_bible.get('books', []): if b.get('book_number') == book.get('book_number'): b['title'] = final_bp['book_metadata'].get('title', b.get('title')) @@ -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." previous_context = f"PREVIOUS BOOK SUMMARY: {last_beat}\nCHARACTERS: {json.dumps(final_bp.get('characters', []))}" - + return + if __name__ == "__main__": target_arg = sys.argv[1] if len(sys.argv) > 1 else None - run_generation(target_arg, interactive=True) \ No newline at end of file + run_generation(target_arg, interactive=True) diff --git a/wizard.py b/cli/wizard.py similarity index 85% rename from wizard.py rename to cli/wizard.py index 5d1fd10..fa3b502 100644 --- a/wizard.py +++ b/cli/wizard.py @@ -1,21 +1,24 @@ import os import sys import json -import config -import google.generativeai as genai from flask import Flask from rich.console import Console from rich.panel import Panel from rich.prompt import Prompt, IntPrompt, Confirm from rich.table import Table -from modules import ai, utils -from modules.web_db import db, User, Project +from core import config, utils +from ai import models as ai_models +from ai import setup as ai_setup +from web.db import db, User, Project +from marketing import cover as marketing_cover +from export import exporter +from cli.engine import run_generation console = Console() try: - ai.init_models() + ai_setup.init_models() except Exception as e: - console.print(f"[bold red]❌ CRITICAL: AI Model Initialization failed.[/bold red]") + console.print(f"[bold red]CRITICAL: AI Model Initialization failed.[/bold red]") console.print(f"[red]Error: {e}[/red]") Prompt.ask("Press Enter to exit...") sys.exit(1) @@ -30,6 +33,7 @@ db.init_app(app) if not os.path.exists(config.PROJECTS_DIR): os.makedirs(config.PROJECTS_DIR) if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR) + class BookWizard: def __init__(self): self.project_name = "New_Project" @@ -43,21 +47,20 @@ class BookWizard: utils.create_default_personas() def _get_or_create_wizard_user(self): - # Find or create a default user for CLI operations wizard_user = User.query.filter_by(username="wizard").first() if not wizard_user: console.print("[yellow]Creating default 'wizard' user for CLI operations...[/yellow]") - wizard_user = User(username="wizard", password="!", is_admin=True) # Password not used for CLI + wizard_user = User(username="wizard", password="!", is_admin=True) db.session.add(wizard_user) db.session.commit() return wizard_user def clear(self): os.system('cls' if os.name == 'nt' else 'clear') - + def ask_gemini_json(self, prompt): text = None try: - response = ai.model_logic.generate_content(prompt + "\nReturn ONLY valid JSON.") + response = ai_models.model_logic.generate_content(prompt + "\nReturn ONLY valid JSON.") text = utils.clean_json(response.text) return json.loads(text) except Exception as e: @@ -67,7 +70,7 @@ class BookWizard: def ask_gemini_text(self, prompt): try: - response = ai.model_logic.generate_content(prompt) + response = ai_models.model_logic.generate_content(prompt) return response.text.strip() except Exception as e: console.print(f"[red]AI Error: {e}[/red]") @@ -81,40 +84,38 @@ class BookWizard: try: with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f) except: pass - - console.print(Panel("[bold cyan]🎭 Manage Author Personas[/bold cyan]")) + + console.print(Panel("[bold cyan]Manage Author Personas[/bold cyan]")) options = list(personas.keys()) - + for i, name in enumerate(options): console.print(f"[{i+1}] {name}") - + console.print(f"[{len(options)+1}] Create New Persona") console.print(f"[{len(options)+2}] Back") console.print(f"[{len(options)+3}] Exit") - + choice = int(Prompt.ask("Select Option", choices=[str(i) for i in range(1, len(options)+4)])) - + if choice == len(options) + 2: break elif choice == len(options) + 3: sys.exit() - + selected_key = None details = {} - + if choice == len(options) + 1: - # Create console.print("[yellow]Define New Persona[/yellow]") selected_key = Prompt.ask("Persona Label (e.g. 'Gritty Detective')", default="New Persona") else: - # Edit/Delete Menu for specific persona selected_key = options[choice-1] details = personas[selected_key] if isinstance(details, str): details = {"bio": details} - + console.print(f"\n[bold]Selected: {selected_key}[/bold]") console.print("1. Edit") console.print("2. Delete") console.print("3. Cancel") - + sub = int(Prompt.ask("Action", choices=["1", "2", "3"], default="1")) if sub == 2: if Confirm.ask(f"Delete '{selected_key}'?", default=False): @@ -123,8 +124,7 @@ class BookWizard: continue elif sub == 3: continue - - # Edit Fields + details['name'] = Prompt.ask("Author Name/Pseudonym", default=details.get('name', "AI Author")) details['age'] = Prompt.ask("Age", default=details.get('age', "Unknown")) details['gender'] = Prompt.ask("Gender", default=details.get('gender', "Unknown")) @@ -132,16 +132,15 @@ class BookWizard: 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['bio'] = Prompt.ask("Writing Style/Bio", default=details.get('bio', "")) - - # Samples + console.print("\n[bold]Style Samples[/bold]") console.print(f"Place text files in the '{config.PERSONAS_DIR}' folder to reference them.") - + curr_files = details.get('sample_files', []) files_str = ",".join(curr_files) 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_text'] = Prompt.ask("Manual Sample Paragraph", default=details.get('sample_text', "")) if Confirm.ask("Save Persona?", default=True): @@ -151,14 +150,14 @@ class BookWizard: def select_mode(self): while True: self.clear() - console.print(Panel("[bold blue]🧙‍♂️ BookApp Setup Wizard[/bold blue]")) + console.print(Panel("[bold blue]BookApp Setup Wizard[/bold blue]")) console.print("1. Create New Project") console.print("2. Open Existing Project") console.print("3. Manage Author Personas") console.print("4. Exit") - + choice = int(Prompt.ask("Select Mode", choices=["1", "2", "3", "4"], default="1")) - + if choice == 1: self.user = self._get_or_create_wizard_user() return self.create_new_project() @@ -166,28 +165,26 @@ class BookWizard: self.user = self._get_or_create_wizard_user() if self.open_existing_project(): return True elif choice == 3: - # Personas don't need a user context self.manage_personas() else: return False def create_new_project(self): self.clear() - console.print(Panel("[bold green]🆕 New Project Setup[/bold green]")) - - # 1. Ask for Concept first to guide defaults + console.print(Panel("[bold green]New Project Setup[/bold green]")) + console.print("Tell me about your story idea (or leave empty to start from scratch).") concept = Prompt.ask("Story Concept") - + suggestions = {} if concept: with console.status("[bold yellow]AI is analyzing your concept...[/bold yellow]"): prompt = f""" ROLE: Publishing Analyst TASK: Suggest metadata for a story concept. - + CONCEPT: {concept} - + OUTPUT_FORMAT (JSON): {{ "title": "String", @@ -217,12 +214,12 @@ class BookWizard: while True: self.clear() - console.print(Panel("[bold green]🤖 AI Suggestions[/bold green]")) - + console.print(Panel("[bold green]AI Suggestions[/bold green]")) + grid = Table.grid(padding=(0, 2)) grid.add_column(style="bold cyan") grid.add_column() - + def get_str(k): v = suggestions.get(k, 'N/A') if isinstance(v, list): return ", ".join(v) @@ -232,28 +229,27 @@ class BookWizard: grid.add_row("Genre:", get_str('genre')) grid.add_row("Audience:", get_str('target_audience')) grid.add_row("Tone:", get_str('tone')) - + len_cat = suggestions.get('length_category', '4') len_label = config.LENGTH_DEFINITIONS.get(len_cat, {}).get('label', 'Novel') grid.add_row("Length:", len_label) grid.add_row("Est. Chapters:", str(suggestions.get('estimated_chapters', 'N/A'))) grid.add_row("Est. Words:", str(suggestions.get('estimated_word_count', 'N/A'))) - grid.add_row("Tropes:", get_str('tropes')) grid.add_row("POV:", get_str('pov_style')) grid.add_row("Time:", get_str('time_period')) grid.add_row("Spice:", get_str('spice')) grid.add_row("Violence:", get_str('violence')) grid.add_row("Series:", "Yes" if suggestions.get('is_series') else "No") - + console.print(grid) console.print("\n[dim]These will be the defaults for the next step.[/dim]") - + console.print("\n1. Continue (Manual Step-through)") console.print("2. Refine Suggestions with AI") - + choice = Prompt.ask("Select Option", choices=["1", "2"], default="1") - + if choice == "1": break else: @@ -262,25 +258,24 @@ class BookWizard: refine_prompt = f""" ROLE: Publishing Analyst TASK: Refine project metadata based on user instruction. - + INPUT_DATA: - CURRENT_JSON: {json.dumps(suggestions)} - INSTRUCTION: {instruction} - + OUTPUT_FORMAT (JSON): Same structure as input. Ensure length_category matches word count. """ new_sugg = self.ask_gemini_json(refine_prompt) if new_sugg: suggestions = new_sugg - # 2. Select Type (with AI default) default_type = "2" if suggestions.get('is_series') else "1" console.print("1. Standalone Book") console.print("2. Series") - + choice = int(Prompt.ask("Select Type", choices=["1", "2"], default=default_type)) is_series = (choice == 2) - + self.configure_details(suggestions, concept, is_series) self.enrich_blueprint() self.refine_blueprint("Review & Edit Bible") @@ -288,24 +283,23 @@ class BookWizard: return True def open_existing_project(self): - # Query projects from the database for the wizard user projects = Project.query.filter_by(user_id=self.user.id).order_by(Project.name).all() if not projects: console.print(f"[red]No projects found for user '{self.user.username}'. Create one first.[/red]") Prompt.ask("Press Enter to continue...") return False - - console.print("\n[bold cyan]📂 Select Project[/bold cyan]") + + console.print("\n[bold cyan]Select Project[/bold cyan]") for i, p in enumerate(projects): console.print(f"[{i+1}] {p.name}") - + console.print(f"[{len(projects)+1}] Back") console.print(f"[{len(projects)+2}] Exit") - + 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) + 2: sys.exit() - + selected_project = projects[choice-1] self.project_name = selected_project.name self.project_path = selected_project.folder_path @@ -315,7 +309,7 @@ class BookWizard: if not self.project_path: console.print("[red]No project loaded.[/red]") return False - + path = os.path.join(self.project_path, "bible.json") if os.path.exists(path): 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): if suggestions is None: suggestions = {} - console.print("\n[bold blue]📝 Project Details[/bold blue]") - - # Simplified Persona Selection (Skip creation) + console.print("\n[bold blue]Project Details[/bold blue]") + personas = {} if os.path.exists(config.PERSONAS_FILE): try: with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f) except: pass - + author_details = {} if personas: console.print("\n[bold]Select Author Persona[/bold]") opts = list(personas.keys()) for i, p in enumerate(opts): console.print(f"[{i+1}] {p}") 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) if sel <= len(opts): author_details = personas[opts[sel-1]] if isinstance(author_details, str): author_details = {"bio": author_details} - + default_author = author_details.get('name', "AI Co-Pilot") author = Prompt.ask("Author Name", default=default_author) - + genre = Prompt.ask("Genre", default=suggestions.get('genre', "Fiction")) - + # LENGTH SELECTION table = Table(title="Target Length Options") table.add_column("#"); table.add_column("Type"); table.add_column("Est. Words"); table.add_column("Chapters") for k, v in config.LENGTH_DEFINITIONS.items(): table.add_row(k, v['label'], v['words'], str(v['chapters'])) console.print(table) - + def_len = suggestions.get('length_category', "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) - # Create a copy so we don't modify the global definition settings = config.LENGTH_DEFINITIONS[len_choice].copy() - - # AI Defaults + def_chapters = suggestions.get('estimated_chapters', settings['chapters']) def_words = suggestions.get('estimated_word_count', settings['words']) def_prologue = suggestions.get('include_prologue', False) @@ -375,9 +366,8 @@ class BookWizard: settings['words'] = Prompt.ask("Target Word Count", default=str(def_words)) settings['include_prologue'] = Confirm.ask("Include Prologue?", default=def_prologue) settings['include_epilogue'] = Confirm.ask("Include Epilogue?", default=def_epilogue) - - # --- GENRE STANDARD CHECK --- - # Parse current word count selection + + # Genre Standard Check w_str = str(settings.get('words', '0')).replace(',', '').replace('+', '').lower() avg_words = 0 if '-' in w_str: @@ -388,7 +378,6 @@ class BookWizard: try: avg_words = int(w_str.replace('k', '000')) except: pass - # Define rough standards std_target = 0 g_lower = genre.lower() if "fantasy" in g_lower or "sci-fi" in g_lower or "space" in g_lower or "epic" in g_lower: std_target = 100000 @@ -397,9 +386,8 @@ class BookWizard: elif "young adult" in g_lower or "ya" in g_lower: std_target = 60000 if std_target > 0 and avg_words > 0: - # If difference is > 25%, warn user if abs(std_target - avg_words) / std_target > 0.25: - console.print(f"\n[bold yellow]⚠️ Genre Advisory:[/bold yellow] Standard length for {genre} is approx {std_target:,} words.") + console.print(f"\n[bold yellow]Genre Advisory:[/bold yellow] Standard length for {genre} is approx {std_target:,} words.") if Confirm.ask(f"Update target to {std_target:,} words?", default=True): settings['words'] = f"{std_target:,}" @@ -408,19 +396,15 @@ class BookWizard: def_tropes = ", ".join(suggestions.get('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 [] - - # TITLE - # If series, this is Series Title. If book, Book Title. + title = Prompt.ask("Book Title (Leave empty for AI)", default=suggestions.get('title', "")) - # PROJECT NAME default_proj = utils.sanitize_filename(title) if title else "New_Project" self.project_name = Prompt.ask("Project Name (Folder)", default=default_proj) - - # Create Project in DB and set path + user_dir = os.path.join(config.DATA_DIR, "users", str(self.user.id)) if not os.path.exists(user_dir): os.makedirs(user_dir) - + self.project_path = os.path.join(user_dir, self.project_name) if os.path.exists(self.project_path): 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]") tone = Prompt.ask("Tone", default=suggestions.get('tone', "Balanced")) - # POV SETTINGS pov_style = Prompt.ask("POV Style (e.g. 'Third Person Limited', 'First Person')", default=suggestions.get('pov_style', "Third Person Limited")) pov_chars_input = Prompt.ask("POV Characters (comma sep, leave empty if single protagonist)", default="") pov_chars = [x.strip() for x in pov_chars_input.split(',')] if pov_chars_input else [] - # ADVANCED STYLE tense = Prompt.ask("Narrative Tense (e.g. 'Past', 'Present')", default=suggestions.get('narrative_tense', "Past")) - + console.print("\n[bold]Content Guidelines[/bold]") 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")) 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")) - + console.print("\n[bold]Formatting & World Rules[/bold]") time_period = Prompt.ask("Time Period/Tech (e.g. 'Modern', '1990s', 'No Cellphones')", default=suggestions.get('time_period', "Modern")) - - # Visuals + orientation = Prompt.ask("Page Orientation", choices=["Portrait", "Landscape", "Square"], default=suggestions.get('page_orientation', "Portrait")) - + console.print("[italic]Define formatting rules (e.g. 'Chapter Headers: POV + Title', 'Text Messages: Italic').[/italic]") 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_rules = [x.strip() for x in fmt_input.split(',')] if fmt_input else [] - # Update book_metadata with new fields style_data = { - "tone": tone, "tropes": sel_tropes, + "tone": tone, "tropes": sel_tropes, "pov_style": pov_style, "pov_characters": pov_chars, - "tense": tense, "spice": spice, "violence": violence, "language": language, - "dialogue_style": dialogue_style, "time_period": time_period, + "tense": tense, "spice": spice, "violence": violence, "language": language, + "dialogue_style": dialogue_style, "time_period": time_period, "page_orientation": orientation, "formatting_rules": fmt_rules } self.data['project_metadata'] = { - "title": title, - "author": author, - "author_details": author_details, - "author_bio": author_details.get('bio', ''), - "genre": genre, + "title": title, + "author": author, + "author_details": author_details, + "author_bio": author_details.get('bio', ''), + "genre": genre, "target_audience": Prompt.ask("Audience", default=suggestions.get('target_audience', "Adult")), "is_series": is_series, "length_settings": settings, "style": style_data } - - # Initialize Books List + self.data['books'] = [] if is_series: count = IntPrompt.ask("How many books in the series?", default=3) @@ -501,20 +480,20 @@ class BookWizard: }) def enrich_blueprint(self): - console.print("\n[bold yellow]✨ Generating full Book Bible (Characters, Plot, etc.)...[/bold yellow]") - + console.print("\n[bold yellow]Generating full Book Bible (Characters, Plot, etc.)...[/bold yellow]") + prompt = f""" ROLE: Creative Director TASK: Create a comprehensive Book Bible. - + INPUT_DATA: - METADATA: {json.dumps(self.data['project_metadata'])} - BOOKS: {json.dumps(self.data['books'])} - + INSTRUCTIONS: 1. Create Main Characters. 2. For EACH book: Generate Title, Plot Summary (manual_instruction), and 10-15 Plot Beats. - + OUTPUT_FORMAT (JSON): {{ "characters": [ {{ "name": "String", "role": "String", "description": "String" }} ], @@ -527,11 +506,9 @@ class BookWizard: if new_data: if 'characters' in new_data: self.data['characters'] = new_data['characters'] - # Filter defaults self.data['characters'] = [c for c in self.data['characters'] if c.get('name') and c.get('name').lower() not in ['name', 'character name', 'role', 'protagonist', 'unknown']] - + if 'books' in new_data: - # Merge book data carefully ai_books = {b.get('book_number'): b for b in new_data['books']} for i, book in enumerate(self.data['books']): b_num = book.get('book_number', i+1) @@ -547,53 +524,40 @@ class BookWizard: meta = data.get('project_metadata', {}) length = meta.get('length_settings', {}) style = meta.get('style', {}) - - # Metadata Grid + grid = Table.grid(padding=(0, 2)) grid.add_column(style="bold cyan") grid.add_column() - + grid.add_row("Title:", meta.get('title', 'N/A')) grid.add_row("Author:", meta.get('author', 'N/A')) grid.add_row("Genre:", meta.get('genre', 'N/A')) grid.add_row("Audience:", meta.get('target_audience', 'N/A')) - - # Dynamic Style Display - # Define explicit order for common fields + ordered_keys = [ "tone", "pov_style", "pov_characters", "tense", "spice", "violence", "language", "dialogue_style", "time_period", "page_orientation", "tropes" ] - defaults = { - "tone": "Balanced", - "pov_style": "Third Person Limited", - "tense": "Past", - "spice": "Standard", - "violence": "Standard", - "language": "Standard", - "dialogue_style": "Standard", - "time_period": "Modern", - "page_orientation": "Portrait" + "tone": "Balanced", "pov_style": "Third Person Limited", "tense": "Past", + "spice": "Standard", "violence": "Standard", "language": "Standard", + "dialogue_style": "Standard", "time_period": "Modern", "page_orientation": "Portrait" } - - # 1. Show ordered keys first + for k in ordered_keys: val = style.get(k) if val in [None, "", "N/A"]: val = defaults.get(k, 'N/A') - if isinstance(val, list): val = ", ".join(val) if isinstance(val, bool): val = "Yes" if val else "No" grid.add_row(f"{k.replace('_', ' ').title()}:", str(val)) - - # 2. Show remaining keys + for k, v in style.items(): if k not in ordered_keys and k != 'formatting_rules': val = ", ".join(v) if isinstance(v, list) else str(v) 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)" extras = [] if length.get('include_prologue'): extras.append("Prologue") @@ -601,32 +565,28 @@ class BookWizard: if extras: len_str += f" + {', '.join(extras)}" grid.add_row("Length:", len_str) grid.add_row("Series:", "Yes" if meta.get('is_series') else "No") - - console.print(Panel(grid, title="[bold blue]📖 Project Metadata[/bold blue]", expand=False)) - - # Formatting Rules Table + + console.print(Panel(grid, title="[bold blue]Project Metadata[/bold blue]", expand=False)) + fmt_rules = style.get('formatting_rules', []) if fmt_rules: fmt_table = Table(title="Formatting Rules", show_header=False, box=None, expand=True) for i, r in enumerate(fmt_rules): fmt_table.add_row(f"[bold]{i+1}.[/bold]", str(r)) - console.print(Panel(fmt_table, title="[bold blue]🎨 Formatting[/bold blue]")) - - # Characters Table - char_table = Table(title="👥 Characters", show_header=True, header_style="bold magenta", expand=True) + console.print(Panel(fmt_table, title="[bold blue]Formatting[/bold blue]")) + + char_table = Table(title="Characters", show_header=True, header_style="bold magenta", expand=True) char_table.add_column("Name", style="green") char_table.add_column("Role") char_table.add_column("Description") for c in data.get('characters', []): - # Removed truncation to show full description char_table.add_row(c.get('name', '-'), c.get('role', '-'), c.get('description', '-')) console.print(char_table) - - # Books List + for book in data.get('books', []): - console.print(f"\n[bold cyan]📘 Book {book.get('book_number')}: {book.get('title')}[/bold cyan]") + console.print(f"\n[bold cyan]Book {book.get('book_number')}: {book.get('title')}[/bold cyan]") console.print(f"[italic]{book.get('manual_instruction')}[/italic]") - + beats = book.get('plot_beats', []) if beats: beat_table = Table(show_header=False, box=None, expand=True) @@ -637,41 +597,40 @@ class BookWizard: def refine_blueprint(self, title="Refine Blueprint"): while True: self.clear() - console.print(Panel(f"[bold blue]🔧 {title}[/bold blue]")) + console.print(Panel(f"[bold blue]{title}[/bold blue]")) self.display_summary(self.data) console.print("\n[dim](Full JSON loaded)[/dim]") - + change = Prompt.ask("\n[bold green]Enter instruction to change (e.g. 'Make it darker', 'Rename Bob', 'Add a twist') or 'done'[/bold green]") if change.lower() == 'done': break - - # Inner loop for refinement + current_data = self.data instruction = change - + while True: with console.status("[bold green]AI is updating blueprint...[/bold green]"): prompt = f""" ROLE: Senior Editor TASK: Update the Bible JSON based on instruction. - + INPUT_DATA: - CURRENT_JSON: {json.dumps(current_data)} - INSTRUCTION: {instruction} - + OUTPUT_FORMAT (JSON): The full updated JSON object. """ new_data = self.ask_gemini_json(prompt) - + if not new_data: console.print("[red]AI failed to generate valid JSON.[/red]") break self.clear() - console.print(Panel("[bold blue]👀 Review AI Changes[/bold blue]")) + console.print(Panel("[bold blue]Review AI Changes[/bold blue]")) self.display_summary(new_data) - + feedback = Prompt.ask("\n[bold green]Is this good? (Type 'yes' to save, or enter feedback to refine)[/bold green]") - + if feedback.lower() == 'yes': self.data = new_data 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) filename = os.path.join(self.project_path, "bible.json") - + with open(filename, 'w') as f: json.dump(self.data, f, indent=2) - console.print(Panel(f"[bold green]✅ Bible saved to: {filename}[/bold green]")) + console.print(Panel(f"[bold green]Bible saved to: {filename}[/bold green]")) return filename def manage_runs(self): @@ -701,7 +660,7 @@ class BookWizard: 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) - + if not runs: console.print("[red]No runs found.[/red]") 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)])) if choice == len(runs) + 1: break elif choice == len(runs) + 2: sys.exit() - + selected_run = runs[choice-1] run_path = os.path.join(runs_dir, selected_run) - self.manage_specific_run(run_path) def manage_specific_run(self, run_path): while True: self.clear() console.print(Panel(f"[bold blue]Run: {os.path.basename(run_path)}[/bold blue]")) - - # Detect sub-books (Series Run) + subdirs = sorted([d for d in os.listdir(run_path) if os.path.isdir(os.path.join(run_path, d)) and d.startswith("Book_")]) - + if subdirs: console.print("[italic]Series Run Detected[/italic]") for i, s in enumerate(subdirs): console.print(f"[{i+1}] Manage {s}") - + idx_open = len(subdirs) + 1 idx_back = len(subdirs) + 2 idx_exit = len(subdirs) + 3 - + console.print(f"[{idx_open}] Open Run Folder") console.print(f"[{idx_back}] Back") console.print(f"[{idx_exit}] Exit") - + choice = int(Prompt.ask("Select Option", choices=[str(i) for i in range(1, idx_exit+1)])) - + if choice <= len(subdirs): book_path = os.path.join(run_path, subdirs[choice-1]) self.manage_single_book_folder(book_path) @@ -764,31 +721,28 @@ class BookWizard: console.print("1. Regenerate Cover & Recompile EPUB") console.print("2. Open Folder") console.print("3. Back") - + choice = int(Prompt.ask("Select Action", choices=["1", "2", "3"])) - + if choice == 1: - import main bp_path = os.path.join(folder_path, "final_blueprint.json") ms_path = os.path.join(folder_path, "manuscript.json") - + if os.path.exists(bp_path) and os.path.exists(ms_path): with console.status("[bold yellow]Regenerating Cover...[/bold yellow]"): with open(bp_path, 'r') as f: bp = json.load(f) with open(ms_path, 'r') as f: ms = json.load(f) - - # Check/Generate Tracking + events_path = os.path.join(folder_path, "tracking_events.json") chars_path = os.path.join(folder_path, "tracking_characters.json") tracking = {"events": [], "characters": {}} - + if os.path.exists(events_path): tracking['events'] = utils.load_json(events_path) if os.path.exists(chars_path): tracking['characters'] = utils.load_json(chars_path) - - main.ai.init_models() - + + ai_setup.init_models() + if not tracking['events'] and not tracking['characters']: - # Fallback: Use Blueprint data console.print("[yellow]Tracking missing. Populating from Blueprint...[/yellow]") tracking['events'] = bp.get('plot_beats', []) tracking['characters'] = {} @@ -801,9 +755,9 @@ class BookWizard: } with open(events_path, 'w') as f: json.dump(tracking['events'], f, indent=2) with open(chars_path, 'w') as f: json.dump(tracking['characters'], f, indent=2) - - main.marketing.generate_cover(bp, folder_path, tracking) - main.export.compile_files(bp, ms, folder_path) + + marketing_cover.generate_cover(bp, folder_path, tracking) + exporter.compile_files(bp, ms, folder_path) console.print("[green]Cover updated and EPUB recompiled![/green]") Prompt.ask("Press Enter...") else: @@ -820,21 +774,22 @@ class BookWizard: else: os.system(f"open '{path}'") + if __name__ == "__main__": w = BookWizard() with app.app_context(): - try: + try: if w.select_mode(): while True: w.clear() - console.print(Panel(f"[bold blue]📂 Project: {w.project_name}[/bold blue]")) + console.print(Panel(f"[bold blue]Project: {w.project_name}[/bold blue]")) console.print("1. Edit Bible") console.print("2. Run Book Generation") console.print("3. Manage Runs") console.print("4. Exit") - + choice = int(Prompt.ask("Select Option", choices=["1", "2", "3", "4"])) - + if choice == 1: if w.load_bible(): w.refine_blueprint("Refine Bible") @@ -842,14 +797,10 @@ if __name__ == "__main__": elif choice == 2: if w.load_bible(): bible_path = os.path.join(w.project_path, "bible.json") - import main - main.run_generation(bible_path, interactive=True) + run_generation(bible_path, interactive=True) Prompt.ask("\nGeneration complete. Press Enter...") elif choice == 3: - # Manage runs w.manage_runs() else: break - else: - pass - except KeyboardInterrupt: console.print("\n[red]Cancelled.[/red]") \ No newline at end of file + except KeyboardInterrupt: console.print("\n[red]Cancelled.[/red]") diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config.py b/core/config.py similarity index 83% rename from config.py rename to core/config.py index f02383b..63b5f1b 100644 --- a/config.py +++ b/core/config.py @@ -1,8 +1,12 @@ import os from dotenv import load_dotenv -# Ensure .env is loaded from the script's directory (VS Code fix) -load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")) +# __file__ is core/config.py; app root is one level up +_HERE = os.path.dirname(os.path.abspath(__file__)) +BASE_DIR = os.path.dirname(_HERE) + +# Ensure .env is loaded from the app root +load_dotenv(os.path.join(BASE_DIR, ".env")) def get_clean_env(key, default=None): val = os.getenv(key, default) @@ -28,7 +32,6 @@ if FLASK_SECRET == "dev-secret-key-change-this": if not API_KEY: raise ValueError("❌ CRITICAL ERROR: GEMINI_API_KEY not found.") # --- DATA DIRECTORIES --- -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = os.path.join(BASE_DIR, "data") PROJECTS_DIR = os.path.join(DATA_DIR, "projects") PERSONAS_DIR = os.path.join(DATA_DIR, "personas") @@ -36,17 +39,14 @@ PERSONAS_FILE = os.path.join(PERSONAS_DIR, "personas.json") FONTS_DIR = os.path.join(DATA_DIR, "fonts") # --- ENSURE DIRECTORIES EXIST --- -# Critical: Create data folders immediately to prevent DB initialization errors for d in [DATA_DIR, PROJECTS_DIR, PERSONAS_DIR, FONTS_DIR]: if not os.path.exists(d): os.makedirs(d, exist_ok=True) # --- AUTHENTICATION --- GOOGLE_CREDS = os.getenv("GOOGLE_APPLICATION_CREDENTIALS") if GOOGLE_CREDS: - # Resolve to absolute path relative to this config file if not absolute if not os.path.isabs(GOOGLE_CREDS): - base = os.path.dirname(os.path.abspath(__file__)) - GOOGLE_CREDS = os.path.join(base, GOOGLE_CREDS) + GOOGLE_CREDS = os.path.join(BASE_DIR, GOOGLE_CREDS) if os.path.exists(GOOGLE_CREDS): os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = GOOGLE_CREDS @@ -65,4 +65,4 @@ LENGTH_DEFINITIONS = { } # --- SYSTEM --- -VERSION = "1.4.0" \ No newline at end of file +VERSION = "1.4.0" diff --git a/modules/utils.py b/core/utils.py similarity index 84% rename from modules/utils.py rename to core/utils.py index 9e327f7..3a55134 100644 --- a/modules/utils.py +++ b/core/utils.py @@ -2,7 +2,7 @@ import os import json import datetime import time -import config +from core import config import threading import re @@ -35,7 +35,6 @@ def update_progress(percent): def clean_json(text): text = text.replace("```json", "").replace("```", "").strip() - # Robust extraction: find first { or [ and last } or ] start_obj = text.find('{') start_arr = text.find('[') if start_obj == -1 and start_arr == -1: return text @@ -45,13 +44,11 @@ def clean_json(text): return text[start_arr:text.rfind(']')+1] def sanitize_filename(name): - """Sanitizes a string to be safe for filenames.""" if not name: return "Untitled" safe = "".join([c for c in name if c.isalnum() or c=='_']).replace(" ", "_") return safe if safe else "Untitled" def chapter_sort_key(ch): - """Sort key for chapters handling integers, strings, Prologue, and Epilogue.""" num = ch.get('num', 0) if isinstance(num, int): return num if isinstance(num, str) and num.isdigit(): return int(num) @@ -61,7 +58,6 @@ def chapter_sort_key(ch): return 999 def get_sorted_book_folders(run_dir): - """Returns a list of book folder names in a run directory, sorted numerically.""" if not os.path.exists(run_dir): return [] subdirs = [d for d in os.listdir(run_dir) if os.path.isdir(os.path.join(run_dir, d)) and d.startswith("Book_")] def sort_key(d): @@ -70,31 +66,26 @@ def get_sorted_book_folders(run_dir): return 0 return sorted(subdirs, key=sort_key) -# --- SHARED UTILS --- def log_banner(phase, title): - """Log a visually distinct phase separator line.""" log(phase, f"{'─' * 18} {title} {'─' * 18}") def log(phase, msg): timestamp = datetime.datetime.now().strftime('%H:%M:%S') line = f"[{timestamp}] {phase:<15} | {msg}" print(line) - - # Write to thread-specific log file if set + if getattr(_log_context, 'log_file', None): with open(_log_context.log_file, "a", encoding="utf-8") as f: f.write(line + "\n") - - # Trigger callback if set (e.g. for Database logging) + if getattr(_log_context, 'callback', None): try: _log_context.callback(phase, msg) except: pass -def load_json(path): +def load_json(path): return json.load(open(path, 'r')) if os.path.exists(path) else None def create_default_personas(): - # Initialize empty personas file if it doesn't exist if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR) if not os.path.exists(config.PERSONAS_FILE): try: @@ -102,7 +93,6 @@ def create_default_personas(): except: pass def get_length_presets(): - """Returns a dict mapping Label -> Settings for use in main.py""" presets = {} for k, v in config.LENGTH_DEFINITIONS.items(): presets[v['label']] = v @@ -145,65 +135,53 @@ def get_latest_run_folder(base_name): return os.path.join(base_name, runs[-1]) def update_pricing(model_name, cost_str): - """Parses cost string from AI selection and updates cache.""" if not model_name or not cost_str or cost_str == 'N/A': return - try: - # Look for patterns like "$0.075 Input" or "$3.50/1M" - # Default to 0.0 in_cost = 0.0 out_cost = 0.0 - - # Extract all float-like numbers following a $ sign prices = re.findall(r'(?:\$|USD)\s*([0-9]+\.?[0-9]*)', cost_str, re.IGNORECASE) - if len(prices) >= 2: in_cost = float(prices[0]) out_cost = float(prices[1]) elif len(prices) == 1: in_cost = float(prices[0]) - out_cost = in_cost * 3 # Rough heuristic if only one price provided - + out_cost = in_cost * 3 if in_cost > 0: PRICING_CACHE[model_name] = {"input": in_cost, "output": out_cost} - # log("SYSTEM", f"Updated pricing for {model_name}: In=${in_cost} | Out=${out_cost}") except: pass def calculate_cost(model_label, input_tokens, output_tokens, image_count=0): cost = 0.0 m = model_label.lower() - - # Check dynamic cache first + if model_label in PRICING_CACHE: rates = PRICING_CACHE[model_label] cost = (input_tokens / 1_000_000 * rates['input']) + (output_tokens / 1_000_000 * rates['output']) elif 'imagen' in m or image_count > 0: cost = (image_count * 0.04) else: - # Fallbacks if 'flash' in m: cost = (input_tokens / 1_000_000 * 0.075) + (output_tokens / 1_000_000 * 0.30) elif 'pro' in m or 'logic' in m: cost = (input_tokens / 1_000_000 * 3.50) + (output_tokens / 1_000_000 * 10.50) - + return round(cost, 6) def log_usage(folder, model_label, usage_metadata=None, image_count=0): if not folder or not os.path.exists(folder): return - + log_path = os.path.join(folder, "usage_log.json") - + input_tokens = 0 output_tokens = 0 - + if usage_metadata: try: input_tokens = usage_metadata.prompt_token_count output_tokens = usage_metadata.candidates_token_count except: pass - # Calculate Cost cost = calculate_cost(model_label, input_tokens, output_tokens, image_count) 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}} - + if os.path.exists(log_path): try: loaded = json.load(open(log_path, 'r')) if isinstance(loaded, list): data["log"] = loaded elif isinstance(loaded, dict): data = loaded except: pass - + data["log"].append(entry) - - # Recalculate totals + t_in = sum(x.get('input_tokens', 0) for x in data["log"]) t_out = sum(x.get('output_tokens', 0) for x in data["log"]) t_img = sum(x.get('images', 0) for x in data["log"]) - + total_cost = 0.0 for x in data["log"]: if 'cost' in x: total_cost += x['cost'] else: - # Fallback calculation for old logs without explicit cost field c = 0.0 mx = x.get('model', '').lower() ix = x.get('input_tokens', 0) ox = x.get('output_tokens', 0) imgx = x.get('images', 0) - + 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 'imagen' in mx or imgx > 0: c = (imgx * 0.04) total_cost += c - + data["totals"] = { "input_tokens": t_in, "output_tokens": t_out, "images": t_img, "est_cost_usd": round(total_cost, 4) } - - with open(log_path, 'w') as f: json.dump(data, f, indent=2) \ No newline at end of file + + with open(log_path, 'w') as f: json.dump(data, f, indent=2) diff --git a/docker-compose.yml b/docker-compose.yml index 94221a6..beab2f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,11 +18,14 @@ services: # --- DEVELOPMENT (Code Sync) --- # UNCOMMENT these lines only if you are developing and want to see changes instantly. # For production/deployment, keep them commented out so the container uses the built image code. - # - ./modules:/app/modules + # - ./core:/app/core + # - ./ai:/app/ai + # - ./story:/app/story + # - ./marketing:/app/marketing + # - ./export:/app/export + # - ./web:/app/web + # - ./cli:/app/cli # - ./templates:/app/templates - # - ./main.py:/app/main.py - # - ./wizard.py:/app/wizard.py - # - ./config.py:/app/config.py environment: - PYTHONUNBUFFERED=1 - GOOGLE_APPLICATION_CREDENTIALS=/app/credentials.json diff --git a/export/__init__.py b/export/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/export.py b/export/exporter.py similarity index 89% rename from modules/export.py rename to export/exporter.py index 3ed4f2f..04a5a74 100644 --- a/modules/export.py +++ b/export/exporter.py @@ -2,7 +2,8 @@ import os import markdown from docx import Document from ebooklib import epub -from . import utils +from core import utils + def create_readme(folder, bp): meta = bp['book_metadata'] @@ -10,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')}""" with open(os.path.join(folder, "README.md"), "w") as f: f.write(content) + def compile_files(bp, ms, folder): utils.log("SYSTEM", "Compiling EPUB and DOCX...") meta = bp.get('book_metadata', {}) title = meta.get('title', 'Untitled') - + if meta.get('filename'): safe = meta['filename'] else: safe = utils.sanitize_filename(title) - + doc = Document(); doc.add_heading(title, 0) book = epub.EpubBook(); book.set_title(title); spine = ['nav'] - - # Add Cover if exists + cover_path = os.path.join(folder, "cover.png") if os.path.exists(cover_path): with open(cover_path, 'rb') as f: book.set_cover("cover.png", f.read()) - # Ensure manuscript is sorted correctly before compiling ms.sort(key=utils.chapter_sort_key) for c in ms: - # Determine filename/type num_str = str(c['num']).lower() if num_str == '0' or 'prologue' in num_str: filename = "prologue.xhtml" @@ -45,14 +44,13 @@ def compile_files(bp, ms, folder): filename = f"ch_{c['num']}.xhtml" default_header = f"Ch {c['num']}: {c['title']}" - # Check for AI-generated header in content content = c['content'].strip() clean_content = content.replace("```markdown", "").replace("```", "").strip() lines = clean_content.split('\n') - + ai_header = None body_content = clean_content - + if lines and lines[0].strip().startswith('# '): ai_header = lines[0].strip().replace('#', '').strip() header = ai_header @@ -62,16 +60,16 @@ def compile_files(bp, ms, folder): doc.add_heading(header, 1) doc.add_paragraph(body_content) - + ch = epub.EpubHtml(title=header, file_name=filename) - + clean_content = clean_content.replace(f"{folder}\\", "").replace(f"{folder}/", "") html_content = markdown.markdown(clean_content) ch.content = html_content if ai_header else f"

{header}

{html_content}" - + book.add_item(ch); spine.append(ch) - + doc.save(os.path.join(folder, f"{safe}.docx")) book.spine = spine; book.add_item(epub.EpubNcx()); book.add_item(epub.EpubNav()) epub.write_epub(os.path.join(folder, f"{safe}.epub"), book, {}) - create_readme(folder, bp) \ No newline at end of file + create_readme(folder, bp) diff --git a/marketing/__init__.py b/marketing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/marketing/assets.py b/marketing/assets.py new file mode 100644 index 0000000..9ea9955 --- /dev/null +++ b/marketing/assets.py @@ -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) diff --git a/marketing/blurb.py b/marketing/blurb.py new file mode 100644 index 0000000..0787167 --- /dev/null +++ b/marketing/blurb.py @@ -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.") diff --git a/modules/marketing.py b/marketing/cover.py similarity index 68% rename from modules/marketing.py rename to marketing/cover.py index cbbe71a..bc1c941 100644 --- a/modules/marketing.py +++ b/marketing/cover.py @@ -4,11 +4,9 @@ import json import shutil import textwrap import subprocess -import requests -from . import utils -import config -from modules import ai -from rich.prompt import Confirm +from core import utils +from ai import models as ai_models +from marketing.fonts import download_font try: from PIL import Image, ImageDraw, ImageFont, ImageStat @@ -16,59 +14,6 @@ try: except ImportError: HAS_PIL = False -def download_font(font_name): - """Attempts to download a Google Font from GitHub.""" - if not font_name: font_name = "Roboto" - if not os.path.exists(config.FONTS_DIR): os.makedirs(config.FONTS_DIR) - - # Handle CSS-style lists (e.g. "Roboto, sans-serif") - if "," in font_name: font_name = font_name.split(",")[0].strip() - - # Handle filenames provided by AI - if font_name.lower().endswith(('.ttf', '.otf')): - font_name = os.path.splitext(font_name)[0] - - font_name = font_name.strip().strip("'").strip('"') - for suffix in ["-Regular", " Regular", " regular", "Regular", " Bold", " Italic"]: - if font_name.endswith(suffix): - font_name = font_name[:-len(suffix)] - font_name = font_name.strip() - - clean_name = font_name.replace(" ", "").lower() - font_filename = f"{clean_name}.ttf" - font_path = os.path.join(config.FONTS_DIR, font_filename) - - if os.path.exists(font_path) and os.path.getsize(font_path) > 1000: - utils.log("ASSETS", f"Using cached font: {font_path}") - return font_path - - utils.log("ASSETS", f"Downloading font: {font_name}...") - compact_name = font_name.replace(" ", "") - title_compact = "".join(x.title() for x in font_name.split()) - - patterns = [ - f"static/{title_compact}-Regular.ttf", f"{title_compact}-Regular.ttf", - f"{title_compact}[wght].ttf", f"{title_compact}[wdth,wght].ttf", - f"static/{compact_name}-Regular.ttf", f"{compact_name}-Regular.ttf", - f"{title_compact}-Regular.otf", - ] - - headers = {"User-Agent": "Mozilla/5.0 (BookApp/1.0)"} - for license_type in ["ofl", "apache", "ufl"]: - base_url = f"https://github.com/google/fonts/raw/main/{license_type}/{clean_name}" - for pattern in patterns: - try: - r = requests.get(f"{base_url}/{pattern}", headers=headers, timeout=5) - if r.status_code == 200 and len(r.content) > 1000: - with open(font_path, 'wb') as f: f.write(r.content) - utils.log("ASSETS", f"✅ Downloaded {font_name} to {font_path}") - return font_path - except Exception: continue - - if clean_name != "roboto": - utils.log("ASSETS", f"⚠️ Font '{font_name}' not found. Falling back to Roboto.") - return download_font("Roboto") - return None def evaluate_image_quality(image_path, prompt, model, folder=None): if not HAS_PIL: return None, "PIL not installed" @@ -86,53 +31,6 @@ def evaluate_image_quality(image_path, prompt, model, folder=None): return data.get('score'), data.get('reason') except Exception as e: return None, str(e) -def generate_blurb(bp, folder): - utils.log("MARKETING", "Generating blurb...") - meta = bp.get('book_metadata', {}) - - # Format beats as a readable list, not raw JSON - beats = bp.get('plot_beats', []) - beats_text = "\n".join(f" - {b}" for b in beats[:6]) if beats else " - (no beats provided)" - - # Format protagonist for the blurb - chars = bp.get('characters', []) - protagonist = next((c for c in chars if 'protagonist' in c.get('role', '').lower()), None) - protagonist_desc = f"{protagonist['name']} — {protagonist.get('description', '')}" if protagonist else "the protagonist" - - prompt = f""" - ROLE: Marketing Copywriter - TASK: Write a compelling back-cover blurb for a {meta.get('genre', 'fiction')} novel. - - BOOK DETAILS: - - TITLE: {meta.get('title')} - - GENRE: {meta.get('genre')} - - AUDIENCE: {meta.get('target_audience', 'General')} - - PROTAGONIST: {protagonist_desc} - - LOGLINE: {bp.get('manual_instruction', '(none)')} - - KEY PLOT BEATS: -{beats_text} - - BLURB STRUCTURE: - 1. HOOK (1-2 sentences): Open with the protagonist's world and the inciting disruption. Make it urgent. - 2. STAKES (2-3 sentences): Raise the central conflict. What does the protagonist stand to lose? - 3. TENSION (1-2 sentences): Hint at the impossible choice or escalating danger without revealing the resolution. - 4. HOOK CLOSE (1 sentence): End with a tantalising question or statement that demands the reader open the book. - - RULES: - - 150-200 words total. - - DO NOT reveal the ending or resolution. - - Match the genre's marketing tone ({meta.get('genre', 'fiction')}: e.g. thriller = urgent/terse, romance = emotionally charged, fantasy = epic/wondrous, horror = dread-laden). - - Use present tense for the blurb voice. - - No "Blurb:", no title prefix, no labels — marketing copy only. - """ - try: - response = ai.model_writer.generate_content(prompt) - utils.log_usage(folder, ai.model_writer.name, response.usage_metadata) - blurb = response.text - with open(os.path.join(folder, "blurb.txt"), "w") as f: f.write(blurb) - with open(os.path.join(folder, "back_cover.txt"), "w") as f: f.write(blurb) - except: - utils.log("MARKETING", "Failed to generate blurb.") def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False): if not HAS_PIL: @@ -141,7 +39,6 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False): utils.log("MARKETING", "Generating cover...") meta = bp.get('book_metadata', {}) - series = bp.get('series_metadata', {}) orientation = meta.get('style', {}).get('page_orientation', 'Portrait') ar = "3:4" @@ -156,31 +53,29 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False): if 'characters' in tracking: visual_context += f"Character Appearances: {json.dumps(tracking['characters'])}\n" - # Feedback Analysis regenerate_image = True design_instruction = "" - - # If existing art exists and no feedback provided, preserve it (Keep Cover feature) + if os.path.exists(os.path.join(folder, "cover_art.png")) and not feedback: regenerate_image = False - + if feedback and feedback.strip(): utils.log("MARKETING", f"Analyzing feedback: '{feedback}'...") analysis_prompt = f""" ROLE: Design Assistant TASK: Analyze user feedback on cover. - + FEEDBACK: "{feedback}" - + DECISION: 1. Keep the current background image but change text/layout/color (REGENERATE_LAYOUT). 2. Create a completely new background image (REGENERATE_IMAGE). - + OUTPUT_FORMAT (JSON): {{ "action": "REGENERATE_LAYOUT" or "REGENERATE_IMAGE", "instruction": "Specific instruction for Art Director" }} """ try: - resp = ai.model_logic.generate_content(analysis_prompt) - utils.log_usage(folder, ai.model_logic.name, resp.usage_metadata) + resp = ai_models.model_logic.generate_content(analysis_prompt) + utils.log_usage(folder, ai_models.model_logic.name, resp.usage_metadata) decision = json.loads(utils.clean_json(resp.text)) if decision.get('action') == 'REGENERATE_LAYOUT': regenerate_image = False @@ -191,7 +86,6 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False): genre = meta.get('genre', 'Fiction') tone = meta.get('style', {}).get('tone', 'Balanced') - # Map genre to visual style suggestion genre_style_map = { 'thriller': 'dark, cinematic, high-contrast photography style', 'mystery': 'moody, atmospheric, noir-inspired painting', @@ -237,46 +131,43 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False): }} """ try: - response = ai.model_artist.generate_content(design_prompt) - utils.log_usage(folder, ai.model_artist.name, response.usage_metadata) + response = ai_models.model_artist.generate_content(design_prompt) + utils.log_usage(folder, ai_models.model_artist.name, response.usage_metadata) design = json.loads(utils.clean_json(response.text)) - + bg_color = design.get('primary_color', '#252570') - text_color = design.get('text_color', '#FFFFFF') - + art_prompt = design.get('art_prompt', f"Cover art for {meta.get('title')}") with open(os.path.join(folder, "cover_art_prompt.txt"), "w") as f: f.write(art_prompt) img = None - image_generated = False width, height = 600, 900 best_img_score = 0 best_img_path = None - + MAX_IMG_ATTEMPTS = 3 if regenerate_image: for i in range(1, MAX_IMG_ATTEMPTS + 1): utils.log("MARKETING", f"Generating cover art (Attempt {i}/{MAX_IMG_ATTEMPTS})...") try: - if not ai.model_image: raise ImportError("No Image Generation Model available.") + if not ai_models.model_image: raise ImportError("No Image Generation Model available.") status = "success" try: - result = ai.model_image.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar) + result = ai_models.model_image.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar) except Exception as e: err_lower = str(e).lower() - # Try fast imagen variant before falling back to legacy - if ai.HAS_VERTEX and ("resource" in err_lower or "quota" in err_lower): + if ai_models.HAS_VERTEX and ("resource" in err_lower or "quota" in err_lower): try: utils.log("MARKETING", "⚠️ Imagen 3 failed. Trying Imagen 3 Fast...") - fb_model = ai.VertexImageModel.from_pretrained("imagen-3.0-fast-generate-001") + fb_model = ai_models.VertexImageModel.from_pretrained("imagen-3.0-fast-generate-001") result = fb_model.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar) status = "success_fast" except Exception: utils.log("MARKETING", "⚠️ Imagen 3 Fast failed. Trying Imagen 2...") - fb_model = ai.VertexImageModel.from_pretrained("imagegeneration@006") + fb_model = ai_models.VertexImageModel.from_pretrained("imagegeneration@006") result = fb_model.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar) status = "success_fallback" else: @@ -297,7 +188,7 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False): f"Score 1-10. Deduct 3 points if any text/watermarks are visible. " f"Deduct 2 if the image is blurry or has deformed anatomy." ) - score, critique = evaluate_image_quality(attempt_path, cover_eval_criteria, ai.model_writer, folder) + score, critique = evaluate_image_quality(attempt_path, cover_eval_criteria, ai_models.model_writer, folder) if score is None: score = 0 utils.log("MARKETING", f" -> Image Score: {score}/10. Critique: {critique}") @@ -310,6 +201,7 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False): else: subprocess.call(('xdg-open', attempt_path)) except: pass + from rich.prompt import Confirm if Confirm.ask(f"Accept cover attempt {i} (Score: {score})?", default=True): best_img_path = attempt_path break @@ -317,12 +209,10 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False): utils.log("MARKETING", "User rejected cover. Retrying...") continue - # Only keep as best if score meets minimum quality bar if score >= 5 and score > best_img_score: best_img_score = score best_img_path = attempt_path elif best_img_path is None and score > 0: - # Accept even low-quality image if we have nothing else best_img_score = score best_img_path = attempt_path @@ -330,7 +220,6 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False): utils.log("MARKETING", " -> High quality image accepted.") break - # Refine prompt based on critique keywords prompt_additions = [] critique_lower = critique.lower() if critique else "" if "scar" in critique_lower or "deform" in critique_lower: @@ -351,106 +240,102 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False): if best_img_path != final_art_path: shutil.copy(best_img_path, final_art_path) img = Image.open(final_art_path).resize((width, height)).convert("RGB") - image_generated = True else: utils.log("MARKETING", "Falling back to solid color cover.") img = Image.new('RGB', (width, height), color=bg_color) utils.log_image_attempt(folder, "cover", art_prompt, "cover.png", "fallback_solid") else: - # Load existing art final_art_path = os.path.join(folder, "cover_art.png") if os.path.exists(final_art_path): utils.log("MARKETING", "Using existing cover art (Layout update only).") img = Image.open(final_art_path).resize((width, height)).convert("RGB") else: utils.log("MARKETING", "Existing art not found. Forcing regeneration.") - # Fallback to solid color if we were supposed to reuse but couldn't find it img = Image.new('RGB', (width, height), color=bg_color) font_path = download_font(design.get('font_name') or 'Arial') best_layout_score = 0 best_layout_path = None - + base_layout_prompt = f""" ROLE: Graphic Designer TASK: Determine text layout coordinates for a 600x900 cover. - + METADATA: - TITLE: {meta.get('title')} - AUTHOR: {meta.get('author')} - GENRE: {meta.get('genre')} - + CONSTRAINT: Do NOT place text over faces. - + OUTPUT_FORMAT (JSON): - {{ - "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" }} + {{ + "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" }} }} """ - + if feedback: base_layout_prompt += f"\nUSER FEEDBACK: {feedback}\nAdjust layout/colors accordingly." - + layout_prompt = base_layout_prompt for attempt in range(1, 6): utils.log("MARKETING", f"Designing text layout (Attempt {attempt}/5)...") try: - response = ai.model_writer.generate_content([layout_prompt, img]) - utils.log_usage(folder, ai.model_writer.name, response.usage_metadata) + response = ai_models.model_writer.generate_content([layout_prompt, img]) + utils.log_usage(folder, ai_models.model_writer.name, response.usage_metadata) layout = json.loads(utils.clean_json(response.text)) if isinstance(layout, list): layout = layout[0] if layout else {} except Exception as e: utils.log("MARKETING", f"Layout generation failed: {e}") continue - + img_copy = img.copy() draw = ImageDraw.Draw(img_copy) - + def draw_element(key, text_override=None): elem = layout.get(key) if not elem: return if isinstance(elem, list): elem = elem[0] if elem else {} text = text_override if text_override else elem.get('text') if not text: return - + f_name = elem.get('font_name') or 'Arial' f_path = download_font(f_name) - try: + try: if f_path: font = ImageFont.truetype(f_path, elem.get('font_size', 40)) else: raise IOError("Font not found") except: font = ImageFont.load_default() - + x, y = elem.get('x', 300), elem.get('y', 450) color = elem.get('color') or '#FFFFFF' - + avg_char_w = font.getlength("A") wrap_w = int(550 / avg_char_w) if avg_char_w > 0 else 20 lines = textwrap.wrap(text, width=wrap_w) - + line_heights = [] for l in lines: bbox = draw.textbbox((0, 0), l, font=font) line_heights.append(bbox[3] - bbox[1] + 10) - + total_h = sum(line_heights) current_y = y - (total_h // 2) - - for i, line in enumerate(lines): + + for idx, line in enumerate(lines): bbox = draw.textbbox((0, 0), line, font=font) lx = x - ((bbox[2] - bbox[0]) / 2) draw.text((lx, current_y), line, font=font, fill=color) - current_y += line_heights[i] + current_y += line_heights[idx] draw_element('title', meta.get('title')) draw_element('author', meta.get('author')) - + attempt_path = os.path.join(folder, f"cover_layout_attempt_{attempt}.png") img_copy.save(attempt_path) - - # Evaluate Layout + eval_prompt = f""" Analyze the text layout for the book title '{meta.get('title')}'. CHECKLIST: @@ -458,19 +343,19 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False): 2. Is the contrast sufficient? 3. Does it look professional? """ - score, critique = evaluate_image_quality(attempt_path, eval_prompt, ai.model_writer, folder) + score, critique = evaluate_image_quality(attempt_path, eval_prompt, ai_models.model_writer, folder) if score is None: score = 0 - + utils.log("MARKETING", f" -> Layout Score: {score}/10. Critique: {critique}") - + if score > best_layout_score: best_layout_score = score best_layout_path = attempt_path - + if score == 10: utils.log("MARKETING", " -> Perfect layout accepted.") break - + layout_prompt = base_layout_prompt + f"\nCRITIQUE OF PREVIOUS ATTEMPT: {critique}\nAdjust position/color to fix this." if best_layout_path: @@ -478,7 +363,3 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False): except Exception as e: utils.log("MARKETING", f"Cover generation failed: {e}") - -def create_marketing_assets(bp, folder, tracking=None, interactive=False): - generate_blurb(bp, folder) - generate_cover(bp, folder, tracking, interactive=interactive) \ No newline at end of file diff --git a/marketing/fonts.py b/marketing/fonts.py new file mode 100644 index 0000000..cc7e983 --- /dev/null +++ b/marketing/fonts.py @@ -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 diff --git a/modules/story.py b/modules/story.py deleted file mode 100644 index 2c6cfa6..0000000 --- a/modules/story.py +++ /dev/null @@ -1,1291 +0,0 @@ -import json -import os -import random -import time -import config -from modules import ai -from . import utils - -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.logic_model_name) - if folder: utils.log_usage(folder, model_name, response.usage_metadata) - new_data = json.loads(utils.clean_json(response.text)) - - # Validate - 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 merge_selected_changes(original, draft, selected_keys): - """Helper to merge specific fields from draft to original bible.""" - # Sort keys to ensure deterministic order - 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('.') - - # Metadata: meta.title - 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] - - # Characters: char.0 - 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]) - - # Books: book.1.title - 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): - """Removes placeholder characters generated by AI.""" - 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 enrich(bp, folder, context=""): - utils.log("ENRICHER", "Fleshing out details from description...") - - # If book_metadata is missing, create empty dict so AI can fill it - 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: - # Merge AI response with existing data (don't overwrite if user provided specific keys) - response = ai.model_logic.generate_content(prompt) - utils.log_usage(folder, ai.model_logic.name, response.usage_metadata) - response_text = response.text - cleaned_json = utils.clean_json(response_text) - ai_data = json.loads(cleaned_json) - - # Smart Merge: Only fill missing fields - 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', []) - - # Merge Style (Flexible) - if 'style' not in bp['book_metadata']: - bp['book_metadata']['style'] = {} - - # Handle AI returning legacy keys or new style key - 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', []) - - # Filter out default names - 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 = [] - - if not beats_context: - 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.model_logic.generate_content(prompt) - utils.log_usage(folder, ai.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}") - - # If events already well exceed the target, only deepen descriptions — don't add more - 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.model_logic.generate_content(prompt) - utils.log_usage(folder, ai.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. - (e.g. a tightly plotted thriller may need fewer; an epic with many subplots may need more.) - - TARGET_WORDS for the whole book: {words} - - Assign pacing to each chapter: Very Fast / Fast / Standard / Slow / Very Slow - Reflect dramatic rhythm — action scenes run fast, emotional beats run 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.model_logic.generate_content(prompt) - utils.log_usage(folder, ai.model_logic.name, response.usage_metadata) - plan = json.loads(utils.clean_json(response.text)) - - # Parse target word count - 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 multipliers — fast chapters are naturally shorter, slow chapters longer - pacing_weight = { - 'very fast': 0.60, 'fast': 0.80, 'standard': 1.00, - 'slow': 1.25, 'very slow': 1.50 - } - # Two-pass: apply pacing weights then normalise to hit total target - 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)) - - # Normalise to keep total close to target - 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 [] - -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.model_logic.generate_content(prompt) - utils.log_usage(folder, ai.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 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]]) - - # Calculate dynamic suggestion count based on length - 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.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', {}) - genre = meta.get('genre', 'Fiction') - - 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.model_logic.generate_content(prompt) - utils.log_usage(folder, ai.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 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.model_logic.generate_content(prompt) - utils.log_usage(folder, ai.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.model_logic.generate_content(prompt) - utils.log_usage(folder, ai.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 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" - - # Strip future planning notes (key_events) from character context — the writer - # should not know what is *planned* to happen; only name, role, and description. - 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/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.model_writer.generate_content(prompt) - utils.log_usage(folder, ai.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}" - - # Refinement Loop - max_attempts = 5 - SCORE_AUTO_ACCEPT = 8 # 8 = professional quality; no marginal gain from extra refinement - 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.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.model_logic.generate_content(full_rewrite_prompt) - utils.log_usage(folder, ai.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']) - - # Cap history to last 2 critiques to avoid token bloat - 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: - # Use Writer model (Flash) for refinement to save costs (Flash 1.5 is sufficient for editing) - resp_refine = ai.model_writer.generate_content(refine_prompt) - utils.log_usage(folder, ai.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 - -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.model_logic.generate_content(prompt) - utils.log_usage(folder, ai.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 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 - - # 1. Extract Text Sample - full_text = "\n".join([c.get('content', '') for c in ms]) - if len(full_text) < 500: return - - # 2. Save Sample File - 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) - - # 3. Update or Create Persona - 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.model_logic.generate_content(prompt) - utils.log_usage(folder, ai.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) - -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.model_logic.generate_content(prompt) - utils.log_usage(folder, ai.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 - -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} - - # Summarize chapters to save tokens (pass full text if small enough, but usually summaries are safer) - chapter_summaries = [] - for ch in manuscript: - text = ch.get('content', '') - # Take first 1000 and last 1000 chars to capture setup and resolution of scenes - 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.model_logic.generate_content(prompt) - utils.log_usage(folder, ai.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}") - - # Find target chapter and previous context - 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 = "" - - # Determine previous chapter logic - 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": - # Find the highest numbered chapter - 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:] # Last 3000 chars for context - - meta = bp.get('book_metadata', {}) - style = meta.get('style', {}) - - # Construct Persona Info (Maintain Voice) - 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" - - # Construct Character Visuals (Load tracking for consistency) - char_visuals = "" - 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 - - # Fix: Define fw_list for the prompt - 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.model_logic.generate_content(prompt) - utils.log_usage(folder, ai.model_logic.name, response.usage_metadata) - try: - data = json.loads(utils.clean_json(response.text)) - return data.get('content'), data.get('summary') - except: - # Fallback if model returns raw text instead of JSON - 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}...") - - # Find the changed chapter - 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: - # Summarize the change to save tokens (Fallback if no summary provided) - 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.model_writer.generate_content(change_summary_prompt) - utils.log_usage(folder, ai.model_writer.name, resp.usage_metadata) - current_context = resp.text - except: - current_context = changed_chap.get('content', '')[-2000:] # Fallback - - original_change_context = current_context - # Iterate subsequent chapters - 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] - - # Optimization: If 2 chapters in a row didn't need changes, assume the ripple has stopped locally. - # Perform Long-Range Scan to see if we need to jump ahead. - if consecutive_no_changes >= 2: - if target_chap['num'] not in potential_impact_chapters: - # Check if we have pending future flags - 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: - # No pending flags. Scan remaining chapters. - 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.model_logic.generate_content(scan_prompt) - utils.log_usage(folder, ai.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 = [] - # Ensure integers - 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 current chapter is still not in the list, skip it - # Safety: Always check non-integer chapters (Prologue/Epilogue) to be safe - 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.model_writer.generate_content(prompt) - utils.log_usage(folder, ai.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.") - # Update context for next iteration using existing text - 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 - # Update context with NEW text - current_context = f"Ch {target_chap['num']} Summary: " + new_text[-2000:] - consecutive_no_changes = 0 - - # Save immediately to prevent data loss if subsequent checks fail - 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 \ No newline at end of file diff --git a/modules/web_app.py b/modules/web_app.py deleted file mode 100644 index a160271..0000000 --- a/modules/web_app.py +++ /dev/null @@ -1,1612 +0,0 @@ -import os -import json -import shutil -import markdown -from functools import wraps -from datetime import datetime, timedelta -from urllib.parse import urlparse, urljoin -from sqlalchemy import func, text -from sqlalchemy.exc import IntegrityError -from flask import Flask, render_template, request, redirect, url_for, flash, send_from_directory, session -from flask_login import LoginManager, login_user, login_required, logout_user, current_user -from werkzeug.security import generate_password_hash, check_password_hash -from .web_db import db, User, Project, Run, LogEntry -from .web_tasks import huey, generate_book_task, regenerate_artifacts_task, rewrite_chapter_task, refine_bible_task -import config -from . import utils -from . import ai -from . import story -from . import export - -# Calculate paths relative to this file (modules/web_app.py) -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 -# Use absolute path for database to avoid Docker path resolution issues -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 = '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) - -# --- 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: - # Ensure admin privileges and sync password with Env Var (allows password reset via Docker) - 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}") - -# --- DECORATORS --- -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('index')) - return f(*args, **kwargs) - return decorated_function - -def is_project_locked(project_id): - """Returns True if the project has any completed runs (Book 1 written).""" - 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 - -# --- ROUTES --- - -@app.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) - -@app.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('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') - -@app.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('register')) - - new_user = User(username=username, password=generate_password_hash(password, method='pbkdf2:sha256')) - # Auto-promote if matches env var - 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('index')) - except IntegrityError: - db.session.rollback() - flash('Username exists') - return redirect(url_for('register')) - return render_template('register.html') - -@app.route('/project/setup', methods=['POST']) -@login_required -def project_setup_wizard(): - concept = request.form.get('concept') - - # Initialize AI if needed - try: ai.init_models() - except: pass - - if not ai.model_logic: - flash("AI models not initialized.") - return redirect(url_for('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.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"} - - # Load Personas for dropdown - 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) - -@app.route('/project/setup/refine', methods=['POST']) -@login_required -def project_setup_refine(): - # Re-run the wizard logic with an instruction - concept = request.form.get('concept') - instruction = request.form.get('refine_instruction') - - # Reconstruct current state from form to pass back to AI - current_state = { - "title": request.form.get('title'), - "genre": request.form.get('genre'), - "target_audience": request.form.get('audience'), - "tone": request.form.get('tone'), - # ... (capture other fields if critical, or just rely on AI re-generating from concept + instruction) - } - - try: ai.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.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('index')) # Fallback - - 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) - -@app.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) - - # Construct Bible from Form Data - length_cat = request.form.get('length_category') - len_def = config.LENGTH_DEFINITIONS.get(length_cat, config.LENGTH_DEFINITIONS['4']).copy() - - # Overrides - 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": [] - } - - # Create Books - 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": [] - }) - - # Enrich via AI immediately (Always, to ensure Bible is full) - try: - ai.init_models() - bible = story.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('view_project', id=new_proj.id)) - -@app.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('index')) - - file = request.files['bible_file'] - if file.filename == '': - flash('No selected file') - return redirect(url_for('index')) - - if file: - try: - bible = json.load(file) - # Basic validation - 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('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('view_project', id=new_proj.id)) - - except Exception as e: - flash(f"Import failed: {str(e)}") - return redirect(url_for('index')) - -@app.route('/project/') -@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 - - # Load Bible - bible_path = os.path.join(proj.folder_path, "bible.json") - bible_data = utils.load_json(bible_path) - - # Check for active refinement or pending draft - 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")) - - # Load Personas for dropdown - 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 - - # Fetch other projects for "Related Series" import - other_projects = Project.query.filter(Project.user_id == current_user.id, Project.id != id).all() - - artifacts = [] - cover_image = None - generated_books = {} # Map book_number -> {status: 'generated', run_id: int, folder: str} - locked = is_project_locked(id) - - # Scan ALL completed runs to find the latest status of each book - 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): - # 1. Scan for Generated Books - for d in os.listdir(run_dir): - if d.startswith("Book_") and os.path.isdir(os.path.join(run_dir, d)): - # Check for manuscript to confirm generation - 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]) - # Only add if we haven't found a newer version (runs are ordered desc) - if b_num not in generated_books: - # Find artifacts for direct download link - 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 - - # Collect Artifacts from Latest Run - if latest_run: - run_dir = os.path.join(proj.folder_path, "runs", f"run_{latest_run.id}") - if os.path.exists(run_dir): - # Find Cover Image (Root or First Book) - 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) - -@app.route('/project//run', methods=['POST']) -@login_required -def run_project(id): - proj = db.session.get(Project, id) or Project.query.get_or_404(id) - - # Create Run Entry - new_run = Run(project_id=id, status="queued") - db.session.add(new_run) - db.session.commit() - - # Trigger Background Task - bible_path = os.path.join(proj.folder_path, "bible.json") - task = generate_book_task(new_run.id, proj.folder_path, bible_path, allow_copy=True) - - return redirect(url_for('view_project', id=id)) - -@app.route('/project//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) - -@app.route('/project//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('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('view_project', id=id)) - -@app.route('/project//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') - - # Create New Project - 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) - - # Copy Bible - 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 - - # Apply AI Instruction if provided - if instruction: - try: - ai.init_models() - bible = story.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('view_project', id=new_proj.id)) - -@app.route('/project//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('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('review_project', id=id)) - - return render_template('bible_comparison.html', project=proj, original=original, new=new_draft) - -@app.route('/project//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('view_project', id=id)) - - # Handle JSON request (AJAX) or Form request - 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 = [] - - # Start Background Task - task = refine_bible_task(proj.folder_path, instruction, source_type, selected_keys) - - return {"status": "queued", "task_id": task.id} - -@app.route('/project//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} - -@app.route('/project//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 = story.merge_selected_changes(original, draft, selected_keys) - - with open(bible_path, 'w') as f: json.dump(original, f, indent=2) - os.remove(draft_path) # Cleanup draft after merge - 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('view_project', id=id)) - -@app.route('/project//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('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": [] # AI will fill this if empty during run - } - bible['books'].append(new_book) - - # If series metadata isn't set, set it - 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('view_project', id=id)) - -@app.route('/project//book//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('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('view_project', id=id)) - -@app.route('/project//delete_book/', 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('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] - # Renumber - for i, b in enumerate(bible['books']): - b['book_number'] = i + 1 - - # Update Series Status (Revert to standalone if only 1 book remains) - 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('view_project', id=id)) - -@app.route('/project//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('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('view_project', id=id)) - -@app.route('/project//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('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('view_project', id=id)) - -@app.route('/project//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('view_run', id=run_id)) - - feedback = request.form.get('feedback') - - # Reset state immediately so UI polls correctly - 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('view_run', id=run_id)) - -@app.route('/run//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() - - # Signal the backend process to stop by creating a .stop file - 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('view_project', id=run.project_id)) - -@app.route('/run//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 - - # Create a new run - new_run = Run(project_id=run.project_id, status="queued") - db.session.add(new_run) - db.session.commit() - - # Check mode: 'resume' (default) vs 'restart' - 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 # Force regeneration if feedback provided to ensure changes are applied - - task = 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('view_project', id=run.project_id)) - -@app.route('/project//revise_book/', 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') - - # Create new run - new_run = Run(project_id=run.project_id, status="queued") - db.session.add(new_run) - db.session.commit() - - # Task: Apply feedback to Bible, Copy all books EXCEPT this one, then Generate - task = 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('view_project', id=run.project_id)) - -@app.route('/run/') -@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 - - # Fetch logs for initial render - 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() - - # Fetch Artifacts for Display - run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}") - - # Detect Books in Run (Series Support) - 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': ''} - - # Artifacts - 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("\\", "/")}) - - # Cover - if os.path.exists(os.path.join(b_path, "cover.png")): - b_info['cover'] = os.path.join(d, "cover.png").replace("\\", "/") - - # Blurb - 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) - - # Load Bible Data for Dropdown - bible_path = os.path.join(run.project.folder_path, "bible.json") - bible_data = utils.load_json(bible_path) - - # Load Tracking Data for Run Details - tracking = {"events": [], "characters": {}, "content_warnings": []} - # We load tracking from the LAST book found to populate the general stats (most up-to-date) - 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") - - # Load safely, defaulting to empty structures if load_json returns None - 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 [] - - # Use dedicated run details template - return render_template('run_details.html', run=run, log_content=log_content, books=books_data, bible=bible_data, tracking=tracking) - -@app.route('/run//status') -@login_required -def run_status(id): - run = db.session.get(Run, id) or Run.query.get_or_404(id) - - # Check status from DB or fallback to log file - - log_content = "" - last_log = None - - # 1. Try Database Logs (Fastest & Best) - 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] - - # 2. Fallback to File (For old runs or if DB logging fails) - 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 - -@app.route('/project//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 - - # Security Check: Prevent path traversal - # Combined check using normpath to ensure it stays within root and catches basic traversal chars - 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 file not found in root, check subfolders (Series Support) - 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) - # Scan all book folders - 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) - -@app.route('/project//read/') -@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 - - # Security Check: Prevent path traversal in 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") - - if not os.path.exists(ms_path): - flash("Manuscript not found.") - return redirect(url_for('view_run', id=run_id)) - - manuscript = utils.load_json(ms_path) - - # Sort by chapter number (Handle Prologue/Epilogue) - manuscript.sort(key=utils.chapter_sort_key) - - # Render Markdown for display - 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) - -@app.route('/project//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') - - # Security Check - 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) - - # Regenerate Artifacts (EPUB/DOCX) to reflect manual edits - 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) - export.compile_files(bp, ms, book_path) - - return "Saved", 200 - return "Error", 500 - -@app.route('/project//check_consistency/') -@login_required -def check_consistency(run_id, book_folder): - run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id) - - # Security Check - 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.init_models() - except: pass - - report = story.analyze_consistency(bp, ms, book_path) - return render_template('consistency_report.html', report=report, run=run, book_folder=book_folder) - -@app.route('/project//sync_book/', 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('read_book', run_id=run_id, book_folder=book_folder)) - - # Security Check - 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('read_book', run_id=run_id, book_folder=book_folder)) - - try: ai.init_models() - except: pass - - # 1. Harvest new characters/info from the EDITED text (Updates BP) - bp = story.harvest_metadata(bp, book_path, ms) - - # 2. Sync Tracking (Ensure new characters exist in tracking file) - 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) - - # 3. Update Persona (Style might have changed during manual edits) - story.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('read_book', run_id=run_id, book_folder=book_folder)) - -@app.route('/project//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 - - # Security Check - if "/" in book_folder or "\\" in book_folder or ".." in book_folder: return {"error": "Invalid book folder"}, 400 - - # Try to convert to int, but allow strings (e.g. "Epilogue") - try: chap_num = int(chap_num) - except: pass - - # Start background task - task = rewrite_chapter_task(run.id, run.project.folder_path, book_folder, chap_num, instruction) - - # Store task ID in session to poll for status - session['rewrite_task_id'] = task.id - - return {"status": "queued", "task_id": task.id}, 202 - -@app.route('/task_status/') -@login_required -def get_task_status(task_id): - try: - task_result = huey.result(task_id, preserve=True) - except Exception as e: - # If Huey errors out, report it as a failed task so the UI can react - return {"status": "completed", "success": False, "error": str(e)} - - if task_result is None: - return {"status": "running"} - else: - return {"status": "completed", "success": task_result} - -@app.route('/logout') -def logout(): - logout_user() - return redirect(url_for('login')) - -@app.route('/debug/routes') -@login_required -@admin_required -def debug_routes(): - output = [] - for rule in app.url_map.iter_rules(): - methods = ','.join(rule.methods) - # Use brackets so they are visible in browser text - rule_str = str(rule).replace('<', '[').replace('>', ']') - line = "{:50s} {:20s} {}".format(rule.endpoint, methods, rule_str) - output.append(line) - return "
" + "\n".join(output) + "
" - -@app.route('/system/optimize_models', methods=['POST']) -@login_required -@admin_required -def optimize_models(): - # Force refresh via AI module (safely handles failures) - try: - ai.init_models(force=True) # Force re-initialization and API scan - - # Refresh Style Guidelines - if ai.model_logic: - story.refresh_style_guidelines(ai.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('index')) - -@app.route('/system/status') -@login_required -def system_status(): - # System Status View: Show AI Models and Quotas - 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.image_model_name, image_source=ai.image_model_source) - -@app.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) - -@app.route('/persona/new') -@login_required -def new_persona(): - return render_template('persona_edit.html', persona={}, name="") - -@app.route('/persona/') -@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('list_personas')) - - return render_template('persona_edit.html', persona=persona, name=name) - -@app.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('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('list_personas')) - -@app.route('/persona/delete/', 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('list_personas')) - -@app.route('/persona/analyze', methods=['POST']) -@login_required -def analyze_persona(): - try: ai.init_models() - except: pass - - if not ai.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.model_logic.generate_content(prompt) - return json.loads(utils.clean_json(response.text)) - except Exception as e: - return {"error": str(e)}, 500 - -# --- ADMIN ROUTES --- - -@app.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) - -@app.route('/admin/user//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_dashboard')) - - user = db.session.get(User, user_id) - if user: - # Delete user data folder - 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 - - # Delete projects (DB cascade handles rows, we handle files) - 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_dashboard')) - -@app.route('/admin/project//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_dashboard')) - -@app.route('/admin/reset', methods=['POST']) -@login_required -@admin_required -def admin_factory_reset(): - # 1. Delete ALL Projects (Files & DB) - 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) - - # 2. Delete ALL Users except Current Admin - 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) - - # 3. Reset Personas to Default - 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_dashboard')) - -@app.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 - - # Aggregate spend per user - 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) - -@app.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_style_guidelines')) - - # Load current (creates defaults if missing) - data = story.get_style_guidelines() - return render_template('admin_style.html', data=data) - -@app.route('/admin/impersonate/') -@login_required -@admin_required -def impersonate_user(user_id): - if user_id == current_user.id: - flash("Cannot impersonate yourself.") - return redirect(url_for('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('index')) - return redirect(url_for('admin_dashboard')) - -@app.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_dashboard')) - return redirect(url_for('index')) - -if __name__ == '__main__': - # Run the Huey consumer in a separate thread for testing on Pi - # For production, run `huey_consumer.py web_tasks.huey` in terminal - import threading - def run_huey(): - from huey.consumer import Consumer - # Subclass to disable signal handling (which fails in threads) - class ThreadedConsumer(Consumer): - def _set_signal_handlers(self): - pass - c = ThreadedConsumer(huey, workers=1, worker_type='thread') - c.run() - - # Configuration - debug_mode = os.environ.get("FLASK_DEBUG", "False").lower() == "true" - - # Run worker if: 1. In reloader child process OR 2. Reloader is disabled (debug=False) - if os.environ.get("WERKZEUG_RUN_MAIN") == "true" or not debug_mode: - threading.Thread(target=run_huey, daemon=True).start() - - app.run(host='0.0.0.0', port=5000, debug=debug_mode) \ No newline at end of file diff --git a/story/__init__.py b/story/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/story/bible_tracker.py b/story/bible_tracker.py new file mode 100644 index 0000000..2f48ded --- /dev/null +++ b/story/bible_tracker.py @@ -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 diff --git a/story/editor.py b/story/editor.py new file mode 100644 index 0000000..19bfdfa --- /dev/null +++ b/story/editor.py @@ -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 diff --git a/story/planner.py b/story/planner.py new file mode 100644 index 0000000..242dd72 --- /dev/null +++ b/story/planner.py @@ -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 [] diff --git a/story/style_persona.py b/story/style_persona.py new file mode 100644 index 0000000..3d360fd --- /dev/null +++ b/story/style_persona.py @@ -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) diff --git a/story/writer.py b/story/writer.py new file mode 100644 index 0000000..c143c89 --- /dev/null +++ b/story/writer.py @@ -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 diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index 7439686..a43ec84 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -7,8 +7,8 @@

System management and user administration.

@@ -41,7 +41,7 @@ {% if u.id != current_user.id %}
- + @@ -67,7 +67,7 @@

Manage global AI writing rules and banned words.

- Edit Style Guidelines + Edit Style Guidelines
diff --git a/templates/admin_spend.html b/templates/admin_spend.html index 2411461..d9f76e0 100644 --- a/templates/admin_spend.html +++ b/templates/admin_spend.html @@ -7,7 +7,7 @@

Aggregate cost analysis per user.

diff --git a/templates/admin_style.html b/templates/admin_style.html index 7b1d003..a3027ea 100644 --- a/templates/admin_style.html +++ b/templates/admin_style.html @@ -5,7 +5,7 @@

Style Guidelines

- Back to Admin + Back to Admin
@@ -36,7 +36,7 @@ -
diff --git a/templates/base.html b/templates/base.html index 3f88024..387e91d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -28,7 +28,7 @@ {% if session.get('original_admin_id') %}
Viewing site as {{ current_user.username }} - Stop Impersonating + Stop Impersonating
{% endif %}