v2.0.0: Modularize project into single-responsibility packages
Replaced monolithic modules/ package with a clean architecture:
- core/ config.py, utils.py
- ai/ models.py (ResilientModel), setup.py (init_models)
- story/ planner.py, writer.py, editor.py, style_persona.py, bible_tracker.py
- marketing/ cover.py, blurb.py, fonts.py, assets.py
- export/ exporter.py
- web/ app.py (Flask factory), db.py, helpers.py, tasks.py, routes/{auth,project,run,persona,admin}.py
- cli/ engine.py (run_generation), wizard.py (BookWizard)
Flask routes split into 5 Blueprints; all templates updated with blueprint-
prefixed url_for() calls. Dockerfile and docker-compose updated to use
web.app entry point and new package paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(wc:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(__NEW_LINE_cd9d4130f0b9d34d__ echo \"=== Root-level files \\(non-hidden, non-dir\\) ===\")",
|
||||
"Bash(ls:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,11 @@ RUN apt-get update && apt-get install -y \
|
||||
|
||||
# Copy requirements files
|
||||
COPY requirements.txt .
|
||||
COPY modules/requirements_web.txt ./modules/
|
||||
COPY web/requirements_web.txt ./web/
|
||||
|
||||
# Install dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install --no-cache-dir -r modules/requirements_web.txt
|
||||
RUN pip install --no-cache-dir -r web/requirements_web.txt
|
||||
|
||||
# Copy the rest of the application
|
||||
COPY . .
|
||||
@@ -28,4 +28,4 @@ ENV PYTHONPATH=/app
|
||||
EXPOSE 5000
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/login')" || exit 1
|
||||
CMD ["python", "-m", "modules.web_app"]
|
||||
CMD ["python", "-m", "web.app"]
|
||||
@@ -1 +0,0 @@
|
||||
# BookApp Modules
|
||||
0
ai/__init__.py
Normal file
0
ai/__init__.py
Normal file
71
ai/models.py
Normal file
71
ai/models.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import warnings
|
||||
import google.generativeai as genai
|
||||
from core import utils
|
||||
|
||||
# Suppress Vertex AI warnings
|
||||
warnings.filterwarnings("ignore", category=UserWarning, module="vertexai")
|
||||
|
||||
try:
|
||||
import vertexai
|
||||
from vertexai.preview.vision_models import ImageGenerationModel as VertexImageModel
|
||||
HAS_VERTEX = True
|
||||
except ImportError:
|
||||
HAS_VERTEX = False
|
||||
|
||||
try:
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
HAS_OAUTH = True
|
||||
except ImportError:
|
||||
HAS_OAUTH = False
|
||||
|
||||
model_logic = None
|
||||
model_writer = None
|
||||
model_artist = None
|
||||
model_image = None
|
||||
logic_model_name = "models/gemini-1.5-pro"
|
||||
writer_model_name = "models/gemini-1.5-flash"
|
||||
artist_model_name = "models/gemini-1.5-flash"
|
||||
image_model_name = None
|
||||
image_model_source = "None"
|
||||
|
||||
|
||||
class ResilientModel:
|
||||
def __init__(self, name, safety_settings, role):
|
||||
self.name = name
|
||||
self.safety_settings = safety_settings
|
||||
self.role = role
|
||||
self.model = genai.GenerativeModel(name, safety_settings=safety_settings)
|
||||
|
||||
def update(self, name):
|
||||
self.name = name
|
||||
self.model = genai.GenerativeModel(name, safety_settings=self.safety_settings)
|
||||
|
||||
def generate_content(self, *args, **kwargs):
|
||||
retries = 0
|
||||
max_retries = 3
|
||||
base_delay = 5
|
||||
|
||||
while True:
|
||||
try:
|
||||
return self.model.generate_content(*args, **kwargs)
|
||||
except Exception as e:
|
||||
err_str = str(e).lower()
|
||||
is_retryable = "429" in err_str or "quota" in err_str or "500" in err_str or "503" in err_str or "504" in err_str or "deadline" in err_str or "internal error" in err_str
|
||||
if is_retryable and retries < max_retries:
|
||||
delay = base_delay * (2 ** retries)
|
||||
utils.log("SYSTEM", f"⚠️ Quota error on {self.role} ({self.name}). Retrying in {delay}s...")
|
||||
time.sleep(delay)
|
||||
|
||||
if retries == 0:
|
||||
utils.log("SYSTEM", "Attempting to re-optimize models to find alternative...")
|
||||
from ai import setup as _setup
|
||||
_setup.init_models(force=True)
|
||||
|
||||
retries += 1
|
||||
continue
|
||||
raise e
|
||||
@@ -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'})")
|
||||
utils.log("SYSTEM", f"Image Generation Provider: {models.image_model_source} ({models.image_model_name or 'unavailable'})")
|
||||
312
ai_blueprint.md
Normal file
312
ai_blueprint.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# AI Blueprint: Modularization Plan
|
||||
|
||||
This blueprint details the strategy to break down the monolithic files (`main.py`, `wizard.py`, `modules/story.py`, `modules/web_app.py`, `modules/marketing.py`, `modules/web_tasks.py`, `modules/ai.py`) into a small-file, Single Responsibility architecture.
|
||||
|
||||
## Proposed Folder Structure
|
||||
|
||||
```
|
||||
c:/Users/thethreemagi/OneDrive/Gemini/BookApp/
|
||||
├── core/
|
||||
│ ├── config.py
|
||||
│ └── utils.py
|
||||
├── ai/
|
||||
│ ├── setup.py
|
||||
│ └── models.py
|
||||
├── story/
|
||||
│ ├── planner.py
|
||||
│ ├── writer.py
|
||||
│ ├── editor.py
|
||||
│ ├── style_persona.py
|
||||
│ └── bible_tracker.py
|
||||
├── marketing/
|
||||
│ ├── blurb.py
|
||||
│ ├── cover.py
|
||||
│ ├── fonts.py
|
||||
│ └── assets.py
|
||||
├── export/
|
||||
│ └── exporter.py
|
||||
├── web/
|
||||
│ ├── app.py
|
||||
│ ├── db.py
|
||||
│ ├── tasks.py
|
||||
│ └── routes/
|
||||
│ ├── auth.py
|
||||
│ ├── project.py
|
||||
│ ├── run.py
|
||||
│ ├── admin.py
|
||||
│ └── persona.py
|
||||
├── cli/
|
||||
│ ├── engine.py
|
||||
│ └── wizard.py
|
||||
```
|
||||
|
||||
## Step-by-Step Migration Details
|
||||
|
||||
### 1. `core/` Module
|
||||
**New File: `core/config.py`**
|
||||
- Moves `config.py` unchanged.
|
||||
|
||||
**New File: `core/utils.py`**
|
||||
- Moves all functions from `modules/utils.py`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import os, json, datetime, time, threading, re
|
||||
from core import config
|
||||
```
|
||||
|
||||
### 2. `ai/` Module (Extracting from `modules/ai.py`)
|
||||
**New File: `ai/models.py`**
|
||||
- Extract `ResilientModel` class, and global model variables (`model_logic`, `model_writer`, etc.).
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import google.generativeai as genai
|
||||
from core import utils
|
||||
```
|
||||
|
||||
**New File: `ai/setup.py`**
|
||||
- Extract `init_models`, `select_best_models`, `get_optimal_model`, `get_default_models`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import os, json, time, warnings
|
||||
import google.generativeai as genai
|
||||
from core import config, utils
|
||||
from ai import models
|
||||
```
|
||||
|
||||
### 3. `story/` Module (Extracting from `modules/story.py`)
|
||||
**New File: `story/planner.py`**
|
||||
- Extract `enrich`, `plan_structure`, `expand`, `create_chapter_plan`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import json, random
|
||||
from core import utils
|
||||
from ai import models as ai_models
|
||||
from story.bible_tracker import filter_characters
|
||||
```
|
||||
|
||||
**New File: `story/writer.py`**
|
||||
- Extract `write_chapter`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import json, os
|
||||
from core import config, utils
|
||||
from ai import models as ai_models
|
||||
from story.style_persona import get_style_guidelines
|
||||
from story.editor import evaluate_chapter_quality
|
||||
```
|
||||
|
||||
**New File: `story/editor.py`**
|
||||
- Extract `evaluate_chapter_quality`, `check_pacing`, `analyze_consistency`, `rewrite_chapter_content`, `check_and_propagate`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import json, os
|
||||
from core import config, utils
|
||||
from ai import models as ai_models
|
||||
from story.style_persona import get_style_guidelines
|
||||
```
|
||||
|
||||
**New File: `story/style_persona.py`**
|
||||
- Extract `get_style_guidelines`, `refresh_style_guidelines`, `create_initial_persona`, `refine_persona`, `update_persona_sample`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import json, os, time
|
||||
from core import config, utils
|
||||
from ai import models as ai_models
|
||||
```
|
||||
|
||||
**New File: `story/bible_tracker.py`**
|
||||
- Extract `merge_selected_changes`, `filter_characters`, `update_tracking`, `harvest_metadata`, `refine_bible`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import json
|
||||
from core import utils
|
||||
from ai import models as ai_models
|
||||
```
|
||||
|
||||
### 4. `marketing/` Module (Extracting from `modules/marketing.py`)
|
||||
**New File: `marketing/fonts.py`**
|
||||
- Extract `download_font`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import os, requests
|
||||
from core import config, utils
|
||||
```
|
||||
|
||||
**New File: `marketing/cover.py`**
|
||||
- Extract `generate_cover`, `evaluate_image_quality`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import os, json, shutil, textwrap, subprocess, sys
|
||||
from core import config, utils
|
||||
from ai import models as ai_models
|
||||
from marketing.fonts import download_font
|
||||
from rich.prompt import Confirm
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
HAS_PIL = True
|
||||
except ImportError:
|
||||
HAS_PIL = False
|
||||
```
|
||||
|
||||
**New File: `marketing/blurb.py`**
|
||||
- Extract `generate_blurb`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import os
|
||||
from core import utils
|
||||
from ai import models as ai_models
|
||||
```
|
||||
|
||||
**New File: `marketing/assets.py`**
|
||||
- Extract `create_marketing_assets`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
from marketing.blurb import generate_blurb
|
||||
from marketing.cover import generate_cover
|
||||
```
|
||||
|
||||
### 5. `export/` Module (Extracting from `modules/export.py`)
|
||||
**New File: `export/exporter.py`**
|
||||
- Extract `create_readme`, `compile_files`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import os, markdown
|
||||
from docx import Document
|
||||
from ebooklib import epub
|
||||
from core import utils
|
||||
```
|
||||
|
||||
### 6. `web/` Module (Extracting from `modules/web_app.py` and `modules/web_tasks.py`)
|
||||
**New File: `web/db.py`**
|
||||
- Extract models `User`, `Project`, `Run`, `LogEntry` from `modules/web_db.py`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import UserMixin
|
||||
from datetime import datetime
|
||||
db = SQLAlchemy()
|
||||
```
|
||||
|
||||
**New File: `web/tasks.py`**
|
||||
- Extract Huey configuration and tasks `generate_book_task`, `regenerate_artifacts_task`, `rewrite_chapter_task`, `refine_bible_task`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import os, json, time, sqlite3, shutil
|
||||
from datetime import datetime
|
||||
from huey import SqliteHuey
|
||||
from core import config, utils
|
||||
from ai import setup as ai_setup
|
||||
from story import planner, writer, editor, style_persona, bible_tracker
|
||||
from marketing import cover, assets
|
||||
from export import exporter
|
||||
from web.db import db, Run, User, Project
|
||||
```
|
||||
|
||||
**New File: `web/routes/auth.py`**
|
||||
- Extract `/login`, `/register`, `/logout`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_user, login_required, logout_user, current_user
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from web.db import db, User
|
||||
```
|
||||
|
||||
**New File: `web/routes/project.py`**
|
||||
- Extract `/`, `/project/setup`, `/project/setup/refine`, `/project/create`, `/project/import`, `/project/<id>`, `/project/<id>/update`, `/project/<id>/clone`, `/project/<id>/add_book`, `/project/<id>/book/<book_num>/update`, `/project/<id>/delete_book/<book_num>`, `/project/<id>/import_characters`, `/project/<id>/set_persona`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import os, json
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from core import config, utils
|
||||
from ai import setup as ai_setup, models as ai_models
|
||||
from web.db import db, Project, Run
|
||||
from story import planner, bible_tracker
|
||||
```
|
||||
|
||||
**New File: `web/routes/run.py`**
|
||||
- Extract `/project/<id>/run`, `/run/<id>`, `/run/<id>/status`, `/run/<id>/stop`, `/run/<id>/restart`, `/project/<run_id>/regenerate_artifacts`, `/project/<run_id>/revise_book/<book>`, `/project/<run_id>/read/<book>`, `/project/<run_id>/save_chapter`, `/project/<run_id>/check_consistency/<book>`, `/project/<run_id>/sync_book/<book>`, `/project/<run_id>/rewrite_chapter`, `/project/<run_id>/download`, `/task_status/<task_id>`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import os, json, markdown
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_from_directory, session
|
||||
from flask_login import login_required, current_user
|
||||
from web.db import db, Project, Run, LogEntry
|
||||
from web.tasks import huey, generate_book_task, regenerate_artifacts_task, rewrite_chapter_task
|
||||
from core import config, utils
|
||||
from story import editor, bible_tracker, style_persona
|
||||
from export import exporter
|
||||
```
|
||||
|
||||
**New File: `web/routes/persona.py`**
|
||||
- Extract `/personas`, `/persona/new`, `/persona/<name>`, `/persona/save`, `/persona/delete/<name>`, `/persona/analyze`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import os, json
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required
|
||||
from core import config, utils
|
||||
from ai import setup as ai_setup, models as ai_models
|
||||
```
|
||||
|
||||
**New File: `web/routes/admin.py`**
|
||||
- Extract `/admin`, `/admin/user/<id>/delete`, `/admin/project/<id>/delete`, `/admin/reset`, `/admin/spend`, `/admin/style`, `/admin/impersonate/<id>`, `/admin/stop_impersonate`, `/debug/routes`, `/system/optimize_models`, `/system/status`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import os, json, shutil
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
|
||||
from flask_login import login_required, current_user, login_user
|
||||
from sqlalchemy import func
|
||||
from web.db import db, User, Project, Run
|
||||
from core import config, utils
|
||||
from story import style_persona
|
||||
from ai import setup as ai_setup, models as ai_models
|
||||
```
|
||||
|
||||
**New File: `web/app.py`**
|
||||
- Flask initialization, registering blueprints, and the `__main__` entry point.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import os
|
||||
from flask import Flask
|
||||
from flask_login import LoginManager
|
||||
from core import config
|
||||
from web.db import db, User
|
||||
```
|
||||
|
||||
### 7. `cli/` Module
|
||||
**New File: `cli/engine.py`**
|
||||
- Extract `process_book` and `run_generation` from `main.py`.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import json, os, time, sys, shutil
|
||||
from rich.prompt import Confirm
|
||||
from core import config, utils
|
||||
from ai import setup as ai_setup
|
||||
from story import planner, writer, editor, style_persona, bible_tracker
|
||||
from marketing import cover, assets
|
||||
from export import exporter
|
||||
```
|
||||
|
||||
**New File: `cli/wizard.py`**
|
||||
- Move `wizard.py` here and update imports.
|
||||
- **Exact Imports:**
|
||||
```python
|
||||
import os, sys, json
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.prompt import Prompt, IntPrompt, Confirm
|
||||
from rich.table import Table
|
||||
from flask import Flask
|
||||
from core import config, utils
|
||||
from ai import setup as ai_setup, models as ai_models
|
||||
from web.db import db, User, Project
|
||||
```
|
||||
|
||||
## HTML Template Changes
|
||||
The `templates/` folder contains HTML files. Ensure any `url_for()` calls reference the correct Blueprint names (e.g., `url_for('auth.login')` instead of `url_for('login')`).
|
||||
|
||||
Awaiting approval to begin creating files and making structural changes.
|
||||
0
cli/__init__.py
Normal file
0
cli/__init__.py
Normal file
@@ -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)
|
||||
run_generation(target_arg, interactive=True)
|
||||
@@ -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]")
|
||||
except KeyboardInterrupt: console.print("\n[red]Cancelled.[/red]")
|
||||
0
core/__init__.py
Normal file
0
core/__init__.py
Normal file
@@ -1,8 +1,12 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Ensure .env is loaded from the script's directory (VS Code fix)
|
||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||
# __file__ is core/config.py; app root is one level up
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
BASE_DIR = os.path.dirname(_HERE)
|
||||
|
||||
# Ensure .env is loaded from the app root
|
||||
load_dotenv(os.path.join(BASE_DIR, ".env"))
|
||||
|
||||
def get_clean_env(key, default=None):
|
||||
val = os.getenv(key, default)
|
||||
@@ -28,7 +32,6 @@ if FLASK_SECRET == "dev-secret-key-change-this":
|
||||
if not API_KEY: raise ValueError("❌ CRITICAL ERROR: GEMINI_API_KEY not found.")
|
||||
|
||||
# --- DATA DIRECTORIES ---
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||
PROJECTS_DIR = os.path.join(DATA_DIR, "projects")
|
||||
PERSONAS_DIR = os.path.join(DATA_DIR, "personas")
|
||||
@@ -36,17 +39,14 @@ PERSONAS_FILE = os.path.join(PERSONAS_DIR, "personas.json")
|
||||
FONTS_DIR = os.path.join(DATA_DIR, "fonts")
|
||||
|
||||
# --- ENSURE DIRECTORIES EXIST ---
|
||||
# Critical: Create data folders immediately to prevent DB initialization errors
|
||||
for d in [DATA_DIR, PROJECTS_DIR, PERSONAS_DIR, FONTS_DIR]:
|
||||
if not os.path.exists(d): os.makedirs(d, exist_ok=True)
|
||||
|
||||
# --- AUTHENTICATION ---
|
||||
GOOGLE_CREDS = os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
|
||||
if GOOGLE_CREDS:
|
||||
# Resolve to absolute path relative to this config file if not absolute
|
||||
if not os.path.isabs(GOOGLE_CREDS):
|
||||
base = os.path.dirname(os.path.abspath(__file__))
|
||||
GOOGLE_CREDS = os.path.join(base, GOOGLE_CREDS)
|
||||
GOOGLE_CREDS = os.path.join(BASE_DIR, GOOGLE_CREDS)
|
||||
|
||||
if os.path.exists(GOOGLE_CREDS):
|
||||
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = GOOGLE_CREDS
|
||||
@@ -65,4 +65,4 @@ LENGTH_DEFINITIONS = {
|
||||
}
|
||||
|
||||
# --- SYSTEM ---
|
||||
VERSION = "1.4.0"
|
||||
VERSION = "1.4.0"
|
||||
@@ -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)
|
||||
|
||||
with open(log_path, 'w') as f: json.dump(data, f, indent=2)
|
||||
@@ -18,11 +18,14 @@ services:
|
||||
# --- DEVELOPMENT (Code Sync) ---
|
||||
# UNCOMMENT these lines only if you are developing and want to see changes instantly.
|
||||
# For production/deployment, keep them commented out so the container uses the built image code.
|
||||
# - ./modules:/app/modules
|
||||
# - ./core:/app/core
|
||||
# - ./ai:/app/ai
|
||||
# - ./story:/app/story
|
||||
# - ./marketing:/app/marketing
|
||||
# - ./export:/app/export
|
||||
# - ./web:/app/web
|
||||
# - ./cli:/app/cli
|
||||
# - ./templates:/app/templates
|
||||
# - ./main.py:/app/main.py
|
||||
# - ./wizard.py:/app/wizard.py
|
||||
# - ./config.py:/app/config.py
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- GOOGLE_APPLICATION_CREDENTIALS=/app/credentials.json
|
||||
|
||||
0
export/__init__.py
Normal file
0
export/__init__.py
Normal file
@@ -2,7 +2,8 @@ import os
|
||||
import markdown
|
||||
from docx import Document
|
||||
from ebooklib import epub
|
||||
from . import utils
|
||||
from core import utils
|
||||
|
||||
|
||||
def create_readme(folder, bp):
|
||||
meta = bp['book_metadata']
|
||||
@@ -10,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"<h1>{header}</h1>{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)
|
||||
create_readme(folder, bp)
|
||||
0
marketing/__init__.py
Normal file
0
marketing/__init__.py
Normal file
7
marketing/assets.py
Normal file
7
marketing/assets.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from marketing.blurb import generate_blurb
|
||||
from marketing.cover import generate_cover
|
||||
|
||||
|
||||
def create_marketing_assets(bp, folder, tracking=None, interactive=False):
|
||||
generate_blurb(bp, folder)
|
||||
generate_cover(bp, folder, tracking, interactive=interactive)
|
||||
51
marketing/blurb.py
Normal file
51
marketing/blurb.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import os
|
||||
import json
|
||||
from core import utils
|
||||
from ai import models as ai_models
|
||||
|
||||
|
||||
def generate_blurb(bp, folder):
|
||||
utils.log("MARKETING", "Generating blurb...")
|
||||
meta = bp.get('book_metadata', {})
|
||||
|
||||
beats = bp.get('plot_beats', [])
|
||||
beats_text = "\n".join(f" - {b}" for b in beats[:6]) if beats else " - (no beats provided)"
|
||||
|
||||
chars = bp.get('characters', [])
|
||||
protagonist = next((c for c in chars if 'protagonist' in c.get('role', '').lower()), None)
|
||||
protagonist_desc = f"{protagonist['name']} — {protagonist.get('description', '')}" if protagonist else "the protagonist"
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Marketing Copywriter
|
||||
TASK: Write a compelling back-cover blurb for a {meta.get('genre', 'fiction')} novel.
|
||||
|
||||
BOOK DETAILS:
|
||||
- TITLE: {meta.get('title')}
|
||||
- GENRE: {meta.get('genre')}
|
||||
- AUDIENCE: {meta.get('target_audience', 'General')}
|
||||
- PROTAGONIST: {protagonist_desc}
|
||||
- LOGLINE: {bp.get('manual_instruction', '(none)')}
|
||||
- KEY PLOT BEATS:
|
||||
{beats_text}
|
||||
|
||||
BLURB STRUCTURE:
|
||||
1. HOOK (1-2 sentences): Open with the protagonist's world and the inciting disruption. Make it urgent.
|
||||
2. STAKES (2-3 sentences): Raise the central conflict. What does the protagonist stand to lose?
|
||||
3. TENSION (1-2 sentences): Hint at the impossible choice or escalating danger without revealing the resolution.
|
||||
4. HOOK CLOSE (1 sentence): End with a tantalising question or statement that demands the reader open the book.
|
||||
|
||||
RULES:
|
||||
- 150-200 words total.
|
||||
- DO NOT reveal the ending or resolution.
|
||||
- Match the genre's marketing tone ({meta.get('genre', 'fiction')}: e.g. thriller = urgent/terse, romance = emotionally charged, fantasy = epic/wondrous, horror = dread-laden).
|
||||
- Use present tense for the blurb voice.
|
||||
- No "Blurb:", no title prefix, no labels — marketing copy only.
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_writer.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_writer.name, response.usage_metadata)
|
||||
blurb = response.text
|
||||
with open(os.path.join(folder, "blurb.txt"), "w") as f: f.write(blurb)
|
||||
with open(os.path.join(folder, "back_cover.txt"), "w") as f: f.write(blurb)
|
||||
except:
|
||||
utils.log("MARKETING", "Failed to generate blurb.")
|
||||
@@ -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)
|
||||
55
marketing/fonts.py
Normal file
55
marketing/fonts.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import os
|
||||
import requests
|
||||
from core import config, utils
|
||||
|
||||
|
||||
def download_font(font_name):
|
||||
if not font_name: font_name = "Roboto"
|
||||
if not os.path.exists(config.FONTS_DIR): os.makedirs(config.FONTS_DIR)
|
||||
|
||||
if "," in font_name: font_name = font_name.split(",")[0].strip()
|
||||
|
||||
if font_name.lower().endswith(('.ttf', '.otf')):
|
||||
font_name = os.path.splitext(font_name)[0]
|
||||
|
||||
font_name = font_name.strip().strip("'").strip('"')
|
||||
for suffix in ["-Regular", " Regular", " regular", "Regular", " Bold", " Italic"]:
|
||||
if font_name.endswith(suffix):
|
||||
font_name = font_name[:-len(suffix)]
|
||||
font_name = font_name.strip()
|
||||
|
||||
clean_name = font_name.replace(" ", "").lower()
|
||||
font_filename = f"{clean_name}.ttf"
|
||||
font_path = os.path.join(config.FONTS_DIR, font_filename)
|
||||
|
||||
if os.path.exists(font_path) and os.path.getsize(font_path) > 1000:
|
||||
utils.log("ASSETS", f"Using cached font: {font_path}")
|
||||
return font_path
|
||||
|
||||
utils.log("ASSETS", f"Downloading font: {font_name}...")
|
||||
compact_name = font_name.replace(" ", "")
|
||||
title_compact = "".join(x.title() for x in font_name.split())
|
||||
|
||||
patterns = [
|
||||
f"static/{title_compact}-Regular.ttf", f"{title_compact}-Regular.ttf",
|
||||
f"{title_compact}[wght].ttf", f"{title_compact}[wdth,wght].ttf",
|
||||
f"static/{compact_name}-Regular.ttf", f"{compact_name}-Regular.ttf",
|
||||
f"{title_compact}-Regular.otf",
|
||||
]
|
||||
|
||||
headers = {"User-Agent": "Mozilla/5.0 (BookApp/1.0)"}
|
||||
for license_type in ["ofl", "apache", "ufl"]:
|
||||
base_url = f"https://github.com/google/fonts/raw/main/{license_type}/{clean_name}"
|
||||
for pattern in patterns:
|
||||
try:
|
||||
r = requests.get(f"{base_url}/{pattern}", headers=headers, timeout=5)
|
||||
if r.status_code == 200 and len(r.content) > 1000:
|
||||
with open(font_path, 'wb') as f: f.write(r.content)
|
||||
utils.log("ASSETS", f"✅ Downloaded {font_name} to {font_path}")
|
||||
return font_path
|
||||
except Exception: continue
|
||||
|
||||
if clean_name != "roboto":
|
||||
utils.log("ASSETS", f"⚠️ Font '{font_name}' not found. Falling back to Roboto.")
|
||||
return download_font("Roboto")
|
||||
return None
|
||||
1291
modules/story.py
1291
modules/story.py
File diff suppressed because it is too large
Load Diff
1612
modules/web_app.py
1612
modules/web_app.py
File diff suppressed because it is too large
Load Diff
0
story/__init__.py
Normal file
0
story/__init__.py
Normal file
144
story/bible_tracker.py
Normal file
144
story/bible_tracker.py
Normal file
@@ -0,0 +1,144 @@
|
||||
import json
|
||||
from core import utils
|
||||
from ai import models as ai_models
|
||||
|
||||
|
||||
def merge_selected_changes(original, draft, selected_keys):
|
||||
def sort_key(k):
|
||||
return [int(p) if p.isdigit() else p for p in k.split('.')]
|
||||
selected_keys.sort(key=sort_key)
|
||||
|
||||
for key in selected_keys:
|
||||
parts = key.split('.')
|
||||
|
||||
if parts[0] == 'meta' and len(parts) == 2:
|
||||
field = parts[1]
|
||||
if field == 'tone':
|
||||
original['project_metadata']['style']['tone'] = draft['project_metadata']['style']['tone']
|
||||
elif field in original['project_metadata']:
|
||||
original['project_metadata'][field] = draft['project_metadata'][field]
|
||||
|
||||
elif parts[0] == 'char' and len(parts) >= 2:
|
||||
idx = int(parts[1])
|
||||
if idx < len(draft['characters']):
|
||||
if idx < len(original['characters']):
|
||||
original['characters'][idx] = draft['characters'][idx]
|
||||
else:
|
||||
original['characters'].append(draft['characters'][idx])
|
||||
|
||||
elif parts[0] == 'book' and len(parts) >= 2:
|
||||
book_num = int(parts[1])
|
||||
orig_book = next((b for b in original['books'] if b['book_number'] == book_num), None)
|
||||
draft_book = next((b for b in draft['books'] if b['book_number'] == book_num), None)
|
||||
|
||||
if draft_book:
|
||||
if not orig_book:
|
||||
original['books'].append(draft_book)
|
||||
original['books'].sort(key=lambda x: x.get('book_number', 999))
|
||||
continue
|
||||
|
||||
if len(parts) == 2:
|
||||
orig_book['title'] = draft_book['title']
|
||||
orig_book['manual_instruction'] = draft_book['manual_instruction']
|
||||
|
||||
elif len(parts) == 4 and parts[2] == 'beat':
|
||||
beat_idx = int(parts[3])
|
||||
if beat_idx < len(draft_book['plot_beats']):
|
||||
while len(orig_book['plot_beats']) <= beat_idx:
|
||||
orig_book['plot_beats'].append("")
|
||||
orig_book['plot_beats'][beat_idx] = draft_book['plot_beats'][beat_idx]
|
||||
return original
|
||||
|
||||
|
||||
def filter_characters(chars):
|
||||
blacklist = ['name', 'character name', 'role', 'protagonist', 'antagonist', 'love interest', 'unknown', 'tbd', 'todo', 'hero', 'villain', 'main character', 'side character']
|
||||
return [c for c in chars if c.get('name') and c.get('name').lower().strip() not in blacklist]
|
||||
|
||||
|
||||
def update_tracking(folder, chapter_num, chapter_text, current_tracking):
|
||||
utils.log("TRACKER", f"Updating world state & character visuals for Ch {chapter_num}...")
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Continuity Tracker
|
||||
TASK: Update the Story Bible based on the new chapter.
|
||||
|
||||
INPUT_TRACKING:
|
||||
{json.dumps(current_tracking)}
|
||||
|
||||
NEW_TEXT:
|
||||
{chapter_text[:20000]}
|
||||
|
||||
OPERATIONS:
|
||||
1. EVENTS: Append 1-3 key plot points to 'events'.
|
||||
2. CHARACTERS: Update 'descriptors', 'likes_dislikes', 'speech_style', 'last_worn', 'major_events'.
|
||||
- "descriptors": List of strings. Add PERMANENT physical traits (height, hair, eyes), specific items (jewelry, weapons). Avoid duplicates.
|
||||
- "likes_dislikes": List of strings. Add specific preferences, likes, or dislikes mentioned (e.g., "Hates coffee", "Loves jazz").
|
||||
- "speech_style": String. Describe how they speak (e.g. "Formal, no contractions", "Uses slang", "Stutters", "Short sentences").
|
||||
- "last_worn": String. Update if specific clothing is described. IMPORTANT: If a significant time jump occurred (e.g. next day) and no new clothing is described, reset this to "Unknown".
|
||||
- "major_events": List of strings. Log significant life-altering events occurring in THIS chapter (e.g. "Lost an arm", "Married", "Betrayed by X").
|
||||
3. WARNINGS: Append new 'content_warnings'.
|
||||
|
||||
OUTPUT_FORMAT (JSON): Return the updated tracking object structure.
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
new_data = json.loads(utils.clean_json(response.text))
|
||||
return new_data
|
||||
except Exception as e:
|
||||
utils.log("TRACKER", f"Failed to update tracking: {e}")
|
||||
return current_tracking
|
||||
|
||||
|
||||
def harvest_metadata(bp, folder, full_manuscript):
|
||||
utils.log("HARVESTER", "Scanning for new characters...")
|
||||
full_text = "\n".join([c.get('content', '') for c in full_manuscript])[:500000]
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Data Extractor
|
||||
TASK: Identify NEW significant characters.
|
||||
|
||||
INPUT_TEXT:
|
||||
{full_text}
|
||||
|
||||
KNOWN_CHARACTERS: {json.dumps(bp['characters'])}
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{ "new_characters": [{{ "name": "String", "role": "String", "description": "String" }}] }}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
new_chars = json.loads(utils.clean_json(response.text)).get('new_characters', [])
|
||||
if new_chars:
|
||||
valid_chars = filter_characters(new_chars)
|
||||
if valid_chars:
|
||||
utils.log("HARVESTER", f"Found {len(valid_chars)} new chars.")
|
||||
bp['characters'].extend(valid_chars)
|
||||
except: pass
|
||||
return bp
|
||||
|
||||
|
||||
def refine_bible(bible, instruction, folder):
|
||||
utils.log("SYSTEM", f"Refining Bible with instruction: {instruction}")
|
||||
prompt = f"""
|
||||
ROLE: Senior Developmental Editor
|
||||
TASK: Update the Bible JSON based on instruction.
|
||||
|
||||
INPUT_DATA:
|
||||
- CURRENT_JSON: {json.dumps(bible)}
|
||||
- INSTRUCTION: {instruction}
|
||||
|
||||
CONSTRAINTS:
|
||||
- Maintain valid JSON structure.
|
||||
- Ensure consistency.
|
||||
|
||||
OUTPUT_FORMAT (JSON): The full updated Bible JSON object.
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
new_data = json.loads(utils.clean_json(response.text))
|
||||
return new_data
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"Refinement failed: {e}")
|
||||
return None
|
||||
399
story/editor.py
Normal file
399
story/editor.py
Normal file
@@ -0,0 +1,399 @@
|
||||
import json
|
||||
import os
|
||||
from core import utils
|
||||
from ai import models as ai_models
|
||||
from story.style_persona import get_style_guidelines
|
||||
|
||||
|
||||
def evaluate_chapter_quality(text, chapter_title, genre, model, folder):
|
||||
guidelines = get_style_guidelines()
|
||||
ai_isms = "', '".join(guidelines['ai_isms'])
|
||||
fw_examples = ", ".join([f"'He {w}'" for w in guidelines['filter_words'][:5]])
|
||||
|
||||
word_count = len(text.split()) if text else 0
|
||||
min_sugg = max(3, int(word_count / 500))
|
||||
max_sugg = min_sugg + 2
|
||||
suggestion_range = f"{min_sugg}-{max_sugg}"
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Senior Literary Editor
|
||||
TASK: Critique chapter draft.
|
||||
|
||||
METADATA:
|
||||
- TITLE: {chapter_title}
|
||||
- GENRE: {genre}
|
||||
|
||||
PROHIBITED_PATTERNS:
|
||||
- AI_ISMS: {ai_isms}
|
||||
- FILTER_WORDS: {fw_examples}
|
||||
- CLICHES: White Room, As You Know Bob, Summary Mode, Anachronisms.
|
||||
- SYNTAX: Repetitive structure, Passive Voice, Adverb Reliance.
|
||||
|
||||
QUALITY_RUBRIC (1-10):
|
||||
1. ENGAGEMENT & TENSION: Does the story grip the reader from the first line? Is there conflict or tension in every scene?
|
||||
2. SCENE EXECUTION: Is the middle of the chapter fully fleshed out? Does it avoid "sagging" or summarizing key moments?
|
||||
3. VOICE & TONE: Is the narrative voice distinct? Does it match the genre?
|
||||
4. SENSORY IMMERSION: Does the text use sensory details effectively without being overwhelming?
|
||||
5. SHOW, DON'T TELL: Are emotions shown through physical reactions and subtext?
|
||||
6. CHARACTER AGENCY: Do characters drive the plot through active choices?
|
||||
7. PACING: Does the chapter feel rushed? Does the ending land with impact, or does it cut off too abruptly?
|
||||
8. GENRE APPROPRIATENESS: Are introductions of characters, places, items, or actions consistent with the {genre} conventions?
|
||||
9. DIALOGUE AUTHENTICITY: Do characters sound distinct? Is there subtext? Avoids "on-the-nose" dialogue.
|
||||
10. PLOT RELEVANCE: Does the chapter advance the plot or character arcs significantly? Avoids filler.
|
||||
11. STAGING & FLOW: Do characters enter/exit physically? Do paragraphs transition logically (Action -> Reaction)?
|
||||
12. PROSE DYNAMICS: Is there sentence variety? Avoids purple prose, adjective stacking, and excessive modification.
|
||||
13. CLARITY & READABILITY: Is the text easy to follow? Are sentences clear and concise?
|
||||
|
||||
SCORING_SCALE:
|
||||
- 10 (Masterpiece): Flawless, impactful, ready for print.
|
||||
- 9 (Bestseller): Exceptional quality, minor style tweaks only.
|
||||
- 7-8 (Professional): Good draft, solid structure, needs editing.
|
||||
- 6 (Passable): Average, has issues with pacing or voice. Needs heavy refinement.
|
||||
- 1-5 (Fail): Structural flaws, boring, or incoherent. Needs rewrite.
|
||||
|
||||
OUTPUT_FORMAT (JSON):
|
||||
{{
|
||||
"score": int,
|
||||
"critique": "Detailed analysis of flaws, citing specific examples from the text.",
|
||||
"actionable_feedback": "List of {suggestion_range} specific, ruthless instructions for the rewrite (e.g. 'Expand the middle dialogue', 'Add sensory details about the rain', 'Dramatize the argument instead of summarizing it')."
|
||||
}}
|
||||
"""
|
||||
try:
|
||||
response = model.generate_content([prompt, text[:30000]])
|
||||
model_name = getattr(model, 'name', ai_models.logic_model_name)
|
||||
utils.log_usage(folder, model_name, response.usage_metadata)
|
||||
data = json.loads(utils.clean_json(response.text))
|
||||
|
||||
critique_text = data.get('critique', 'No critique provided.')
|
||||
if data.get('actionable_feedback'):
|
||||
critique_text += "\n\nREQUIRED FIXES:\n" + str(data.get('actionable_feedback'))
|
||||
|
||||
return data.get('score', 0), critique_text
|
||||
except Exception as e:
|
||||
return 0, f"Evaluation error: {str(e)}"
|
||||
|
||||
|
||||
def check_pacing(bp, summary, last_chapter_text, last_chapter_data, remaining_chapters, folder):
|
||||
utils.log("ARCHITECT", "Checking pacing and structure health...")
|
||||
|
||||
if not remaining_chapters:
|
||||
return None
|
||||
|
||||
meta = bp.get('book_metadata', {})
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Structural Editor
|
||||
TASK: Analyze pacing.
|
||||
|
||||
CONTEXT:
|
||||
- PREVIOUS_SUMMARY: {summary[-3000:]}
|
||||
- CURRENT_CHAPTER: {last_chapter_text[-2000:]}
|
||||
- UPCOMING: {json.dumps([c['title'] for c in remaining_chapters[:3]])}
|
||||
- REMAINING_COUNT: {len(remaining_chapters)}
|
||||
|
||||
LOGIC:
|
||||
- IF skipped major beats -> ADD_BRIDGE
|
||||
- IF covered next chapter's beats -> CUT_NEXT
|
||||
- ELSE -> OK
|
||||
|
||||
OUTPUT_FORMAT (JSON):
|
||||
{{
|
||||
"status": "ok" or "add_bridge" or "cut_next",
|
||||
"reason": "Explanation...",
|
||||
"new_chapter": {{ "title": "...", "beats": ["..."], "pov_character": "..." }} (Required if add_bridge)
|
||||
}}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
return json.loads(utils.clean_json(response.text))
|
||||
except Exception as e:
|
||||
utils.log("ARCHITECT", f"Pacing check failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def analyze_consistency(bp, manuscript, folder):
|
||||
utils.log("EDITOR", "Analyzing manuscript for continuity errors...")
|
||||
|
||||
if not manuscript: return {"issues": ["No manuscript found."], "score": 0}
|
||||
if not bp: return {"issues": ["No blueprint found."], "score": 0}
|
||||
|
||||
chapter_summaries = []
|
||||
for ch in manuscript:
|
||||
text = ch.get('content', '')
|
||||
excerpt = text[:1000] + "\n...\n" + text[-1000:] if len(text) > 2000 else text
|
||||
chapter_summaries.append(f"Ch {ch.get('num')}: {excerpt}")
|
||||
|
||||
context = "\n".join(chapter_summaries)
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Continuity Editor
|
||||
TASK: Analyze book summary for plot holes.
|
||||
|
||||
INPUT_DATA:
|
||||
- CHARACTERS: {json.dumps(bp.get('characters', []))}
|
||||
- SUMMARIES:
|
||||
{context}
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{ "issues": ["Issue 1", "Issue 2"], "score": 8, "summary": "Brief overall assessment." }}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
return json.loads(utils.clean_json(response.text))
|
||||
except Exception as e:
|
||||
return {"issues": [f"Analysis failed: {e}"], "score": 0, "summary": "Error during analysis."}
|
||||
|
||||
|
||||
def rewrite_chapter_content(bp, manuscript, chapter_num, instruction, folder):
|
||||
utils.log("WRITER", f"Rewriting Ch {chapter_num} with instruction: {instruction}")
|
||||
|
||||
target_chap = next((c for c in manuscript if str(c.get('num')) == str(chapter_num)), None)
|
||||
if not target_chap: return None
|
||||
|
||||
prev_text = ""
|
||||
prev_chap = None
|
||||
if isinstance(chapter_num, int):
|
||||
prev_chap = next((c for c in manuscript if c['num'] == chapter_num - 1), None)
|
||||
elif str(chapter_num).lower() == "epilogue":
|
||||
numbered_chaps = [c for c in manuscript if isinstance(c['num'], int)]
|
||||
if numbered_chaps:
|
||||
prev_chap = max(numbered_chaps, key=lambda x: x['num'])
|
||||
|
||||
if prev_chap:
|
||||
prev_text = prev_chap.get('content', '')[-3000:]
|
||||
|
||||
meta = bp.get('book_metadata', {})
|
||||
|
||||
ad = meta.get('author_details', {})
|
||||
if not ad and 'author_bio' in meta:
|
||||
persona_info = meta['author_bio']
|
||||
else:
|
||||
persona_info = f"Name: {ad.get('name', meta.get('author', 'Unknown'))}\n"
|
||||
if ad.get('bio'): persona_info += f"Style/Bio: {ad['bio']}\n"
|
||||
|
||||
char_visuals = ""
|
||||
from core import config
|
||||
tracking_path = os.path.join(folder, "tracking_characters.json")
|
||||
if os.path.exists(tracking_path):
|
||||
try:
|
||||
tracking_chars = utils.load_json(tracking_path)
|
||||
if tracking_chars:
|
||||
char_visuals = "\nCHARACTER TRACKING (Visuals & Preferences):\n"
|
||||
for name, data in tracking_chars.items():
|
||||
desc = ", ".join(data.get('descriptors', []))
|
||||
speech = data.get('speech_style', 'Unknown')
|
||||
char_visuals += f"- {name}: {desc}\n * Speech: {speech}\n"
|
||||
except: pass
|
||||
|
||||
guidelines = get_style_guidelines()
|
||||
fw_list = '", "'.join(guidelines['filter_words'])
|
||||
|
||||
prompt = f"""
|
||||
You are an expert fiction writing AI. Your task is to rewrite a specific chapter based on a user directive.
|
||||
|
||||
INPUT DATA:
|
||||
- TITLE: {meta.get('title')}
|
||||
- GENRE: {meta.get('genre')}
|
||||
- TONE: {meta.get('style', {}).get('tone')}
|
||||
- AUTHOR_VOICE: {persona_info}
|
||||
- PREVIOUS_CONTEXT: {prev_text}
|
||||
- CURRENT_DRAFT: {target_chap.get('content', '')[:5000]}
|
||||
- CHARACTERS: {json.dumps(bp.get('characters', []))}
|
||||
{char_visuals}
|
||||
|
||||
PRIMARY DIRECTIVE (USER INSTRUCTION):
|
||||
{instruction}
|
||||
|
||||
EXECUTION RULES:
|
||||
1. CONTINUITY: The new text must flow logically from PREVIOUS_CONTEXT.
|
||||
2. ADHERENCE: The PRIMARY DIRECTIVE overrides any conflicting details in CURRENT_DRAFT.
|
||||
3. VOICE: Strictly emulate the AUTHOR_VOICE.
|
||||
4. GENRE: Enforce {meta.get('genre')} conventions. No anachronisms.
|
||||
5. LOGIC: Enforce strict causality (Action -> Reaction). No teleporting characters.
|
||||
|
||||
PROSE OPTIMIZATION RULES (STRICT ENFORCEMENT):
|
||||
- FILTER_REMOVAL: Scan for words [{fw_list}]. If found, rewrite the sentence to remove the filter and describe the sensation directly.
|
||||
- SENTENCE_VARIETY: Penalize consecutive sentences starting with the same pronoun or article. Vary structure.
|
||||
- SHOW_DONT_TELL: Convert internal summaries of emotion into physical actions or subtextual dialogue.
|
||||
- ACTIVE_VOICE: Convert passive voice ("was [verb]ed") to active voice.
|
||||
- SENSORY_ANCHORING: The first paragraph must establish the setting using at least one non-visual sense (smell, sound, touch).
|
||||
- SUBTEXT: Dialogue must imply meaning rather than stating it outright.
|
||||
|
||||
RETURN JSON:
|
||||
{{
|
||||
"content": "The full chapter text in Markdown...",
|
||||
"summary": "A concise summary of the chapter's events and ending state (for continuity checks)."
|
||||
}}
|
||||
"""
|
||||
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
try:
|
||||
data = json.loads(utils.clean_json(response.text))
|
||||
return data.get('content'), data.get('summary')
|
||||
except:
|
||||
return response.text, None
|
||||
except Exception as e:
|
||||
utils.log("WRITER", f"Rewrite failed: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def check_and_propagate(bp, manuscript, changed_chap_num, folder, change_summary=None):
|
||||
utils.log("WRITER", f"Checking ripple effects from Ch {changed_chap_num}...")
|
||||
|
||||
changed_chap = next((c for c in manuscript if c['num'] == changed_chap_num), None)
|
||||
if not changed_chap: return None
|
||||
|
||||
if change_summary:
|
||||
current_context = change_summary
|
||||
else:
|
||||
change_summary_prompt = f"""
|
||||
ROLE: Summarizer
|
||||
TASK: Summarize the key events and ending state of this chapter for continuity tracking.
|
||||
|
||||
TEXT:
|
||||
{changed_chap.get('content', '')[:10000]}
|
||||
|
||||
FOCUS:
|
||||
- Major plot points.
|
||||
- Character status changes (injuries, items acquired, location changes).
|
||||
- New information revealed.
|
||||
|
||||
OUTPUT: Concise text summary.
|
||||
"""
|
||||
try:
|
||||
resp = ai_models.model_writer.generate_content(change_summary_prompt)
|
||||
utils.log_usage(folder, ai_models.model_writer.name, resp.usage_metadata)
|
||||
current_context = resp.text
|
||||
except:
|
||||
current_context = changed_chap.get('content', '')[-2000:]
|
||||
|
||||
original_change_context = current_context
|
||||
sorted_ms = sorted(manuscript, key=utils.chapter_sort_key)
|
||||
start_index = -1
|
||||
for i, c in enumerate(sorted_ms):
|
||||
if str(c['num']) == str(changed_chap_num):
|
||||
start_index = i
|
||||
break
|
||||
|
||||
if start_index == -1 or start_index == len(sorted_ms) - 1:
|
||||
return None
|
||||
|
||||
changes_made = False
|
||||
consecutive_no_changes = 0
|
||||
potential_impact_chapters = []
|
||||
|
||||
for i in range(start_index + 1, len(sorted_ms)):
|
||||
target_chap = sorted_ms[i]
|
||||
|
||||
if consecutive_no_changes >= 2:
|
||||
if target_chap['num'] not in potential_impact_chapters:
|
||||
future_flags = [n for n in potential_impact_chapters if isinstance(n, int) and isinstance(target_chap['num'], int) and n > target_chap['num']]
|
||||
|
||||
if not future_flags:
|
||||
remaining_chaps = sorted_ms[i:]
|
||||
if not remaining_chaps: break
|
||||
|
||||
utils.log("WRITER", " -> Short-term ripple dissipated. Scanning remaining chapters for long-range impacts...")
|
||||
|
||||
chapter_summaries = []
|
||||
for rc in remaining_chaps:
|
||||
text = rc.get('content', '')
|
||||
excerpt = text[:500] + "\n...\n" + text[-500:] if len(text) > 1000 else text
|
||||
chapter_summaries.append(f"Ch {rc['num']}: {excerpt}")
|
||||
|
||||
scan_prompt = f"""
|
||||
ROLE: Continuity Scanner
|
||||
TASK: Identify chapters impacted by a change.
|
||||
|
||||
CHANGE_CONTEXT:
|
||||
{original_change_context}
|
||||
|
||||
CHAPTER_SUMMARIES:
|
||||
{json.dumps(chapter_summaries)}
|
||||
|
||||
CRITERIA: Identify later chapters that mention items, characters, or locations involved in the Change Context.
|
||||
|
||||
OUTPUT_FORMAT (JSON): [Chapter_Number_Int, ...]
|
||||
"""
|
||||
|
||||
try:
|
||||
resp = ai_models.model_logic.generate_content(scan_prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, resp.usage_metadata)
|
||||
potential_impact_chapters = json.loads(utils.clean_json(resp.text))
|
||||
if not isinstance(potential_impact_chapters, list): potential_impact_chapters = []
|
||||
potential_impact_chapters = [int(x) for x in potential_impact_chapters if str(x).isdigit()]
|
||||
except Exception as e:
|
||||
utils.log("WRITER", f" -> Scan failed: {e}. Stopping.")
|
||||
break
|
||||
|
||||
if not potential_impact_chapters:
|
||||
utils.log("WRITER", " -> No long-range impacts detected. Stopping.")
|
||||
break
|
||||
else:
|
||||
utils.log("WRITER", f" -> Detected potential impact in chapters: {potential_impact_chapters}")
|
||||
|
||||
if isinstance(target_chap['num'], int) and target_chap['num'] not in potential_impact_chapters:
|
||||
utils.log("WRITER", f" -> Skipping Ch {target_chap['num']} (Not flagged).")
|
||||
continue
|
||||
|
||||
utils.log("WRITER", f" -> Checking Ch {target_chap['num']} for continuity...")
|
||||
|
||||
chap_word_count = len(target_chap.get('content', '').split())
|
||||
prompt = f"""
|
||||
ROLE: Continuity Checker
|
||||
TASK: Determine if a chapter contradicts a story change. If it does, rewrite it to fix the contradiction.
|
||||
|
||||
CHANGED_CHAPTER: {changed_chap_num}
|
||||
CHANGE_SUMMARY: {current_context}
|
||||
|
||||
CHAPTER_TO_CHECK (Ch {target_chap['num']}):
|
||||
{target_chap['content'][:12000]}
|
||||
|
||||
DECISION_LOGIC:
|
||||
- If the chapter directly contradicts the change (references dead characters, items that no longer exist, events that didn't happen), status = REWRITE.
|
||||
- If the chapter is consistent or only tangentially related, status = NO_CHANGE.
|
||||
- Be conservative — only rewrite if there is a genuine contradiction.
|
||||
|
||||
REWRITE_RULES (apply only if REWRITE):
|
||||
- Fix the specific contradiction. Preserve all other content.
|
||||
- The rewritten chapter MUST be approximately {chap_word_count} words (same length as original).
|
||||
- Include the chapter header formatted as Markdown H1.
|
||||
- Do not add new plot points not in the original.
|
||||
|
||||
OUTPUT_FORMAT (JSON):
|
||||
{{
|
||||
"status": "NO_CHANGE" or "REWRITE",
|
||||
"reason": "Brief explanation of the contradiction or why it's consistent",
|
||||
"content": "Full Markdown rewritten chapter (ONLY if status is REWRITE, otherwise null)"
|
||||
}}
|
||||
"""
|
||||
|
||||
try:
|
||||
response = ai_models.model_writer.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_writer.name, response.usage_metadata)
|
||||
data = json.loads(utils.clean_json(response.text))
|
||||
|
||||
if data.get('status') == 'NO_CHANGE':
|
||||
utils.log("WRITER", f" -> Ch {target_chap['num']} is consistent.")
|
||||
current_context = f"Ch {target_chap['num']} Summary: " + target_chap.get('content', '')[-2000:]
|
||||
consecutive_no_changes += 1
|
||||
elif data.get('status') == 'REWRITE' and data.get('content'):
|
||||
new_text = data.get('content')
|
||||
if new_text:
|
||||
utils.log("WRITER", f" -> Rewriting Ch {target_chap['num']} to fix continuity.")
|
||||
target_chap['content'] = new_text
|
||||
changes_made = True
|
||||
current_context = f"Ch {target_chap['num']} Summary: " + new_text[-2000:]
|
||||
consecutive_no_changes = 0
|
||||
|
||||
try:
|
||||
with open(os.path.join(folder, "manuscript.json"), 'w') as f: json.dump(manuscript, f, indent=2)
|
||||
except: pass
|
||||
|
||||
except Exception as e:
|
||||
utils.log("WRITER", f" -> Check failed: {e}")
|
||||
|
||||
return manuscript if changes_made else None
|
||||
265
story/planner.py
Normal file
265
story/planner.py
Normal file
@@ -0,0 +1,265 @@
|
||||
import json
|
||||
import random
|
||||
from core import utils
|
||||
from ai import models as ai_models
|
||||
from story.bible_tracker import filter_characters
|
||||
|
||||
|
||||
def enrich(bp, folder, context=""):
|
||||
utils.log("ENRICHER", "Fleshing out details from description...")
|
||||
|
||||
if 'book_metadata' not in bp: bp['book_metadata'] = {}
|
||||
if 'characters' not in bp: bp['characters'] = []
|
||||
if 'plot_beats' not in bp: bp['plot_beats'] = []
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Creative Director
|
||||
TASK: Create a comprehensive Book Bible from the user description.
|
||||
|
||||
INPUT DATA:
|
||||
- USER_DESCRIPTION: "{bp.get('manual_instruction', 'A generic story')}"
|
||||
- CONTEXT (Sequel): {context}
|
||||
|
||||
STEPS:
|
||||
1. Generate a catchy Title.
|
||||
2. Define the Genre and Tone.
|
||||
3. Determine the Time Period (e.g. "Modern", "1920s", "Sci-Fi Future").
|
||||
4. Define Formatting Rules for text messages, thoughts, and chapter headers.
|
||||
5. Create Protagonist and Antagonist/Love Interest.
|
||||
- Logic: If sequel, reuse context. If new, create.
|
||||
6. Outline 5-7 core Plot Beats.
|
||||
7. Define a 'structure_prompt' describing the narrative arc (e.g. "Hero's Journey", "3-Act Structure", "Detective Procedural").
|
||||
|
||||
OUTPUT_FORMAT (JSON):
|
||||
{{
|
||||
"book_metadata": {{ "title": "Book Title", "genre": "Genre", "content_warnings": ["Violence", "Major Character Death"], "structure_prompt": "...", "style": {{ "tone": "Tone", "time_period": "Modern", "formatting_rules": ["Chapter Headers: Number + Title", "Text Messages: Italic", "Thoughts: Italic"] }} }},
|
||||
"characters": [ {{ "name": "John Doe", "role": "Protagonist", "description": "Description", "key_events": ["Planned injury in Act 2"] }} ],
|
||||
"plot_beats": [ "Beat 1", "Beat 2", "..." ]
|
||||
}}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
ai_data = json.loads(utils.clean_json(response.text))
|
||||
|
||||
if 'book_metadata' not in bp: bp['book_metadata'] = {}
|
||||
|
||||
if 'title' not in bp['book_metadata']:
|
||||
bp['book_metadata']['title'] = ai_data.get('book_metadata', {}).get('title')
|
||||
if 'structure_prompt' not in bp['book_metadata']:
|
||||
bp['book_metadata']['structure_prompt'] = ai_data.get('book_metadata', {}).get('structure_prompt')
|
||||
if 'content_warnings' not in bp['book_metadata']:
|
||||
bp['book_metadata']['content_warnings'] = ai_data.get('book_metadata', {}).get('content_warnings', [])
|
||||
|
||||
if 'style' not in bp['book_metadata']: bp['book_metadata']['style'] = {}
|
||||
|
||||
source_style = ai_data.get('book_metadata', {}).get('style', {})
|
||||
for k, v in source_style.items():
|
||||
if k not in bp['book_metadata']['style']:
|
||||
bp['book_metadata']['style'][k] = v
|
||||
|
||||
if 'characters' not in bp or not bp['characters']:
|
||||
bp['characters'] = ai_data.get('characters', [])
|
||||
|
||||
if 'characters' in bp:
|
||||
bp['characters'] = filter_characters(bp['characters'])
|
||||
|
||||
if 'plot_beats' not in bp or not bp['plot_beats']:
|
||||
bp['plot_beats'] = ai_data.get('plot_beats', [])
|
||||
|
||||
return bp
|
||||
except Exception as e:
|
||||
utils.log("ENRICHER", f"Enrichment failed: {e}")
|
||||
return bp
|
||||
|
||||
|
||||
def plan_structure(bp, folder):
|
||||
utils.log("ARCHITECT", "Creating structure...")
|
||||
|
||||
structure_type = bp.get('book_metadata', {}).get('structure_prompt')
|
||||
|
||||
if not structure_type:
|
||||
label = bp.get('length_settings', {}).get('label', 'Novel')
|
||||
structures = {
|
||||
"Chapter Book": "Create a simple episodic structure with clear chapter hooks.",
|
||||
"Young Adult": "Create a character-driven arc with high emotional stakes and a clear 'Coming of Age' theme.",
|
||||
"Flash Fiction": "Create a single, impactful scene structure with a twist.",
|
||||
"Short Story": "Create a concise narrative arc (Inciting Incident -> Rising Action -> Climax -> Resolution).",
|
||||
"Novella": "Create a standard 3-Act Structure.",
|
||||
"Novel": "Create a detailed 3-Act Structure with A and B plots.",
|
||||
"Epic": "Create a complex, multi-arc structure (Hero's Journey) with extensive world-building events."
|
||||
}
|
||||
structure_type = structures.get(label, "Create a 3-Act Structure.")
|
||||
|
||||
beats_context = bp.get('plot_beats', [])
|
||||
target_chapters = bp.get('length_settings', {}).get('chapters', 'flexible')
|
||||
target_words = bp.get('length_settings', {}).get('words', 'flexible')
|
||||
chars_summary = [{"name": c.get("name"), "role": c.get("role")} for c in bp.get('characters', [])]
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Story Architect
|
||||
TASK: Create a detailed structural event outline for a {target_chapters}-chapter book.
|
||||
|
||||
BOOK:
|
||||
- TITLE: {bp['book_metadata']['title']}
|
||||
- GENRE: {bp.get('book_metadata', {}).get('genre', 'Fiction')}
|
||||
- TARGET_CHAPTERS: {target_chapters}
|
||||
- TARGET_WORDS: {target_words}
|
||||
- STRUCTURE: {structure_type}
|
||||
|
||||
CHARACTERS: {json.dumps(chars_summary)}
|
||||
|
||||
USER_BEATS (must all be preserved and woven into the outline):
|
||||
{json.dumps(beats_context)}
|
||||
|
||||
REQUIREMENTS:
|
||||
- Produce enough events to fill approximately {target_chapters} chapters.
|
||||
- Each event must serve a narrative purpose (setup, escalation, reversal, climax, resolution).
|
||||
- Distribute events across a beginning, middle, and end — avoid front-loading.
|
||||
- Character arcs must be visible through the events (growth, change, revelation).
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{ "events": [{{ "description": "String", "purpose": "String" }}] }}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
return json.loads(utils.clean_json(response.text))['events']
|
||||
except:
|
||||
return []
|
||||
|
||||
|
||||
def expand(events, pass_num, target_chapters, bp, folder):
|
||||
utils.log("ARCHITECT", f"Expansion pass {pass_num} | Current Beats: {len(events)} | Target Chaps: {target_chapters}")
|
||||
|
||||
event_ceiling = int(target_chapters * 1.5)
|
||||
if len(events) >= event_ceiling:
|
||||
task = (
|
||||
f"The outline already has {len(events)} beats for a {target_chapters}-chapter book — do NOT add more events. "
|
||||
f"Instead, enrich each existing beat's description with more specific detail: setting, characters involved, emotional stakes, and how it connects to what follows."
|
||||
)
|
||||
else:
|
||||
task = (
|
||||
f"Expand the outline toward {target_chapters} chapters. "
|
||||
f"Current count: {len(events)} beats. "
|
||||
f"Add intermediate events to fill pacing gaps, deepen subplots, and ensure character arcs are visible. "
|
||||
f"Do not overshoot — aim for {target_chapters} to {event_ceiling} total events."
|
||||
)
|
||||
|
||||
original_beats = bp.get('plot_beats', [])
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Story Architect
|
||||
TASK: {task}
|
||||
|
||||
ORIGINAL_USER_BEATS (must all remain present):
|
||||
{json.dumps(original_beats)}
|
||||
|
||||
CURRENT_EVENTS:
|
||||
{json.dumps(events)}
|
||||
|
||||
RULES:
|
||||
1. PRESERVE all original user beats — do not remove or alter them.
|
||||
2. New events must serve a clear narrative purpose (tension, character, world, reversal).
|
||||
3. Avoid repetitive events — each beat must be distinct.
|
||||
4. Distribute additions evenly — do not front-load the outline.
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{ "events": [{{"description": "String", "purpose": "String"}}] }}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
new_events = json.loads(utils.clean_json(response.text))['events']
|
||||
|
||||
if len(new_events) > len(events):
|
||||
utils.log("ARCHITECT", f" -> Added {len(new_events) - len(events)} new beats.")
|
||||
elif len(str(new_events)) > len(str(events)) + 20:
|
||||
utils.log("ARCHITECT", f" -> Fleshed out descriptions (Text grew by {len(str(new_events)) - len(str(events))} chars).")
|
||||
else:
|
||||
utils.log("ARCHITECT", " -> No significant changes.")
|
||||
return new_events
|
||||
except Exception as e:
|
||||
utils.log("ARCHITECT", f" -> Pass skipped due to error: {e}")
|
||||
return events
|
||||
|
||||
|
||||
def create_chapter_plan(events, bp, folder):
|
||||
utils.log("ARCHITECT", "Finalizing Chapters...")
|
||||
target = bp['length_settings']['chapters']
|
||||
words = bp['length_settings'].get('words', 'Flexible')
|
||||
|
||||
include_prologue = bp.get('length_settings', {}).get('include_prologue', False)
|
||||
include_epilogue = bp.get('length_settings', {}).get('include_epilogue', False)
|
||||
|
||||
structure_instructions = ""
|
||||
if include_prologue: structure_instructions += "- Include a 'Prologue' (chapter_number: 0) to set the scene.\n"
|
||||
if include_epilogue: structure_instructions += "- Include an 'Epilogue' (chapter_number: 'Epilogue') to wrap up.\n"
|
||||
|
||||
meta = bp.get('book_metadata', {})
|
||||
style = meta.get('style', {})
|
||||
pov_chars = style.get('pov_characters', [])
|
||||
pov_instruction = ""
|
||||
if pov_chars:
|
||||
pov_instruction = f"- Assign a 'pov_character' for each chapter from this list: {json.dumps(pov_chars)}."
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Pacing Specialist
|
||||
TASK: Group the provided events into chapters for a {meta.get('genre', 'Fiction')} {bp['length_settings'].get('label', 'novel')}.
|
||||
|
||||
GUIDELINES:
|
||||
- AIM for approximately {target} chapters, but the final count may vary ±15% if the story structure demands it.
|
||||
- TARGET_WORDS for the whole book: {words}
|
||||
- Assign pacing to each chapter: Very Fast / Fast / Standard / Slow / Very Slow
|
||||
- estimated_words per chapter should reflect its pacing:
|
||||
Very Fast ≈ 60% of average, Fast ≈ 80%, Standard ≈ 100%, Slow ≈ 125%, Very Slow ≈ 150%
|
||||
- Do NOT force equal word counts. Natural variation makes the book feel alive.
|
||||
{structure_instructions}
|
||||
{pov_instruction}
|
||||
|
||||
INPUT_EVENTS: {json.dumps(events)}
|
||||
|
||||
OUTPUT_FORMAT (JSON): [{{"chapter_number": 1, "title": "String", "pov_character": "String", "pacing": "String", "estimated_words": 2000, "beats": ["String"]}}]
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
plan = json.loads(utils.clean_json(response.text))
|
||||
|
||||
target_str = str(words).lower().replace(',', '').replace('k', '000').replace('+', '').replace(' ', '')
|
||||
target_val = 0
|
||||
if '-' in target_str:
|
||||
try:
|
||||
parts = target_str.split('-')
|
||||
target_val = int((int(parts[0]) + int(parts[1])) / 2)
|
||||
except: pass
|
||||
else:
|
||||
try: target_val = int(target_str)
|
||||
except: pass
|
||||
|
||||
if target_val > 0:
|
||||
variance = random.uniform(0.92, 1.08)
|
||||
target_val = int(target_val * variance)
|
||||
utils.log("ARCHITECT", f"Word target after variance ({variance:.2f}x): {target_val} words.")
|
||||
|
||||
current_sum = sum(int(c.get('estimated_words', 0)) for c in plan)
|
||||
if current_sum > 0:
|
||||
base_factor = target_val / current_sum
|
||||
pacing_weight = {
|
||||
'very fast': 0.60, 'fast': 0.80, 'standard': 1.00,
|
||||
'slow': 1.25, 'very slow': 1.50
|
||||
}
|
||||
for c in plan:
|
||||
pw = pacing_weight.get(c.get('pacing', 'standard').lower(), 1.0)
|
||||
c['estimated_words'] = max(300, int(c.get('estimated_words', 0) * base_factor * pw))
|
||||
|
||||
adjusted_sum = sum(c['estimated_words'] for c in plan)
|
||||
if adjusted_sum > 0:
|
||||
norm = target_val / adjusted_sum
|
||||
for c in plan:
|
||||
c['estimated_words'] = max(300, int(c['estimated_words'] * norm))
|
||||
|
||||
utils.log("ARCHITECT", f"Chapter lengths scaled by pacing. Total ≈ {sum(c['estimated_words'] for c in plan)} words across {len(plan)} chapters.")
|
||||
|
||||
return plan
|
||||
except Exception as e:
|
||||
utils.log("ARCHITECT", f"Failed to create chapter plan: {e}")
|
||||
return []
|
||||
180
story/style_persona.py
Normal file
180
story/style_persona.py
Normal file
@@ -0,0 +1,180 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from core import config, utils
|
||||
from ai import models as ai_models
|
||||
|
||||
|
||||
def get_style_guidelines():
|
||||
defaults = {
|
||||
"ai_isms": [
|
||||
'testament to', 'tapestry', 'shiver down spine', 'unspoken agreement',
|
||||
'palpable tension', 'a sense of', 'suddenly', 'in that moment',
|
||||
'symphony of', 'dance of', 'azure', 'cerulean'
|
||||
],
|
||||
"filter_words": [
|
||||
'felt', 'saw', 'heard', 'realized', 'decided', 'noticed', 'knew', 'thought'
|
||||
]
|
||||
}
|
||||
path = os.path.join(config.DATA_DIR, "style_guidelines.json")
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
user_data = utils.load_json(path)
|
||||
if user_data:
|
||||
if 'ai_isms' in user_data: defaults['ai_isms'] = user_data['ai_isms']
|
||||
if 'filter_words' in user_data: defaults['filter_words'] = user_data['filter_words']
|
||||
except: pass
|
||||
else:
|
||||
try:
|
||||
with open(path, 'w') as f: json.dump(defaults, f, indent=2)
|
||||
except: pass
|
||||
return defaults
|
||||
|
||||
|
||||
def refresh_style_guidelines(model, folder=None):
|
||||
utils.log("SYSTEM", "Refreshing Style Guidelines via AI...")
|
||||
current = get_style_guidelines()
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Literary Editor
|
||||
TASK: Update 'Banned Words' lists for AI writing.
|
||||
|
||||
INPUT_DATA:
|
||||
- CURRENT_AI_ISMS: {json.dumps(current.get('ai_isms', []))}
|
||||
- CURRENT_FILTER_WORDS: {json.dumps(current.get('filter_words', []))}
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. Review lists. Remove false positives.
|
||||
2. Add new common AI tropes (e.g. 'neon-lit', 'bustling', 'a sense of', 'mined', 'delved').
|
||||
3. Ensure robustness.
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{ "ai_isms": [strings], "filter_words": [strings] }}
|
||||
"""
|
||||
try:
|
||||
response = model.generate_content(prompt)
|
||||
model_name = getattr(model, 'name', ai_models.logic_model_name)
|
||||
if folder: utils.log_usage(folder, model_name, response.usage_metadata)
|
||||
new_data = json.loads(utils.clean_json(response.text))
|
||||
|
||||
if 'ai_isms' in new_data and 'filter_words' in new_data:
|
||||
path = os.path.join(config.DATA_DIR, "style_guidelines.json")
|
||||
with open(path, 'w') as f: json.dump(new_data, f, indent=2)
|
||||
utils.log("SYSTEM", "Style Guidelines updated.")
|
||||
return new_data
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"Failed to refresh guidelines: {e}")
|
||||
return current
|
||||
|
||||
|
||||
def create_initial_persona(bp, folder):
|
||||
utils.log("SYSTEM", "Generating initial Author Persona based on genre/tone...")
|
||||
meta = bp.get('book_metadata', {})
|
||||
style = meta.get('style', {})
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Creative Director
|
||||
TASK: Create a fictional 'Author Persona'.
|
||||
|
||||
METADATA:
|
||||
- TITLE: {meta.get('title')}
|
||||
- GENRE: {meta.get('genre')}
|
||||
- TONE: {style.get('tone')}
|
||||
- AUDIENCE: {meta.get('target_audience')}
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{ "name": "Pen Name", "bio": "Description of writing style (voice, sentence structure, vocabulary)...", "age": "...", "gender": "..." }}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
return json.loads(utils.clean_json(response.text))
|
||||
except Exception as e:
|
||||
utils.log("SYSTEM", f"Persona generation failed: {e}")
|
||||
return {"name": "AI Author", "bio": "Standard, balanced writing style."}
|
||||
|
||||
|
||||
def refine_persona(bp, text, folder):
|
||||
utils.log("SYSTEM", "Refining Author Persona based on recent chapters...")
|
||||
ad = bp.get('book_metadata', {}).get('author_details', {})
|
||||
current_bio = ad.get('bio', 'Standard style.')
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Literary Stylist
|
||||
TASK: Refine Author Bio based on text sample.
|
||||
|
||||
INPUT_DATA:
|
||||
- TEXT_SAMPLE: {text[:3000]}
|
||||
- CURRENT_BIO: {current_bio}
|
||||
|
||||
GOAL: Ensure future chapters sound exactly like the sample. Highlight quirks, patterns, vocabulary.
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{ "bio": "Updated bio..." }}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
new_bio = json.loads(utils.clean_json(response.text)).get('bio')
|
||||
if new_bio:
|
||||
ad['bio'] = new_bio
|
||||
utils.log("SYSTEM", " -> Persona bio updated.")
|
||||
return ad
|
||||
except: pass
|
||||
return ad
|
||||
|
||||
|
||||
def update_persona_sample(bp, folder):
|
||||
utils.log("SYSTEM", "Extracting author persona from manuscript...")
|
||||
|
||||
ms_path = os.path.join(folder, "manuscript.json")
|
||||
if not os.path.exists(ms_path): return
|
||||
ms = utils.load_json(ms_path)
|
||||
if not ms: return
|
||||
|
||||
full_text = "\n".join([c.get('content', '') for c in ms])
|
||||
if len(full_text) < 500: return
|
||||
|
||||
if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR)
|
||||
|
||||
meta = bp.get('book_metadata', {})
|
||||
safe_title = utils.sanitize_filename(meta.get('title', 'book'))[:20]
|
||||
timestamp = int(time.time())
|
||||
filename = f"sample_{safe_title}_{timestamp}.txt"
|
||||
filepath = os.path.join(config.PERSONAS_DIR, filename)
|
||||
|
||||
sample_text = full_text[:3000]
|
||||
with open(filepath, 'w', encoding='utf-8') as f: f.write(sample_text)
|
||||
|
||||
author_name = meta.get('author', 'Unknown Author')
|
||||
|
||||
personas = {}
|
||||
if os.path.exists(config.PERSONAS_FILE):
|
||||
try:
|
||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
||||
except: pass
|
||||
|
||||
if author_name not in personas:
|
||||
utils.log("SYSTEM", f"Generating new persona profile for '{author_name}'...")
|
||||
prompt = f"""
|
||||
ROLE: Literary Analyst
|
||||
TASK: Analyze writing style (Tone, Voice, Vocabulary).
|
||||
TEXT: {sample_text[:1000]}
|
||||
OUTPUT: 1-sentence author bio.
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
bio = response.text.strip()
|
||||
except: bio = "Style analysis unavailable."
|
||||
|
||||
personas[author_name] = {
|
||||
"name": author_name,
|
||||
"bio": bio,
|
||||
"sample_files": [filename],
|
||||
"sample_text": sample_text[:500]
|
||||
}
|
||||
else:
|
||||
utils.log("SYSTEM", f"Updating persona '{author_name}' with new sample.")
|
||||
if 'sample_files' not in personas[author_name]: personas[author_name]['sample_files'] = []
|
||||
if filename not in personas[author_name]['sample_files']:
|
||||
personas[author_name]['sample_files'].append(filename)
|
||||
|
||||
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2)
|
||||
278
story/writer.py
Normal file
278
story/writer.py
Normal file
@@ -0,0 +1,278 @@
|
||||
import json
|
||||
import os
|
||||
from core import config, utils
|
||||
from ai import models as ai_models
|
||||
from story.style_persona import get_style_guidelines
|
||||
from story.editor import evaluate_chapter_quality
|
||||
|
||||
|
||||
def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None, next_chapter_hint=""):
|
||||
pacing = chap.get('pacing', 'Standard')
|
||||
est_words = chap.get('estimated_words', 'Flexible')
|
||||
utils.log("WRITER", f"Drafting Ch {chap['chapter_number']} ({pacing} | ~{est_words} words): {chap['title']}")
|
||||
ls = bp['length_settings']
|
||||
meta = bp.get('book_metadata', {})
|
||||
style = meta.get('style', {})
|
||||
genre = meta.get('genre', 'Fiction')
|
||||
|
||||
pov_char = chap.get('pov_character', '')
|
||||
|
||||
ad = meta.get('author_details', {})
|
||||
if not ad and 'author_bio' in meta:
|
||||
persona_info = meta['author_bio']
|
||||
else:
|
||||
persona_info = f"Name: {ad.get('name', meta.get('author', 'Unknown'))}\n"
|
||||
if ad.get('age'): persona_info += f"Age: {ad['age']}\n"
|
||||
if ad.get('gender'): persona_info += f"Gender: {ad['gender']}\n"
|
||||
if ad.get('race'): persona_info += f"Race: {ad['race']}\n"
|
||||
if ad.get('nationality'): persona_info += f"Nationality: {ad['nationality']}\n"
|
||||
if ad.get('language'): persona_info += f"Language: {ad['language']}\n"
|
||||
if ad.get('bio'): persona_info += f"Style/Bio: {ad['bio']}\n"
|
||||
|
||||
samples = []
|
||||
if ad.get('sample_text'):
|
||||
samples.append(f"--- SAMPLE PARAGRAPH ---\n{ad['sample_text']}")
|
||||
|
||||
if ad.get('sample_files'):
|
||||
for fname in ad['sample_files']:
|
||||
fpath = os.path.join(config.PERSONAS_DIR, fname)
|
||||
if os.path.exists(fpath):
|
||||
try:
|
||||
with open(fpath, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read(3000)
|
||||
samples.append(f"--- SAMPLE FROM {fname} ---\n{content}...")
|
||||
except: pass
|
||||
|
||||
if samples:
|
||||
persona_info += "\nWRITING STYLE SAMPLES:\n" + "\n".join(samples)
|
||||
|
||||
char_visuals = ""
|
||||
if tracking and 'characters' in tracking:
|
||||
char_visuals = "\nCHARACTER TRACKING (Visuals & Preferences):\n"
|
||||
for name, data in tracking['characters'].items():
|
||||
desc = ", ".join(data.get('descriptors', []))
|
||||
likes = ", ".join(data.get('likes_dislikes', []))
|
||||
speech = data.get('speech_style', 'Unknown')
|
||||
worn = data.get('last_worn', 'Unknown')
|
||||
char_visuals += f"- {name}: {desc}\n * Speech: {speech}\n * Likes/Dislikes: {likes}\n"
|
||||
|
||||
major = data.get('major_events', [])
|
||||
if major: char_visuals += f" * Major Events: {'; '.join(major)}\n"
|
||||
|
||||
if worn and worn != 'Unknown':
|
||||
char_visuals += f" * Last Worn: {worn} (NOTE: Only relevant if scene is continuous from previous chapter)\n"
|
||||
|
||||
style_block = "\n".join([f"- {k.replace('_', ' ').title()}: {v}" for k, v in style.items() if isinstance(v, (str, int, float))])
|
||||
if 'tropes' in style and isinstance(style['tropes'], list):
|
||||
style_block += f"\n- Tropes: {', '.join(style['tropes'])}"
|
||||
|
||||
if 'formatting_rules' in style and isinstance(style['formatting_rules'], list):
|
||||
style_block += "\n- Formatting Rules:\n * " + "\n * ".join(style['formatting_rules'])
|
||||
|
||||
prev_context_block = ""
|
||||
if prev_content:
|
||||
trunc_content = prev_content[-3000:] if len(prev_content) > 3000 else prev_content
|
||||
prev_context_block = f"\nPREVIOUS CHAPTER TEXT (For Tone & Continuity):\n{trunc_content}\n"
|
||||
|
||||
chars_for_writer = [
|
||||
{"name": c.get("name"), "role": c.get("role"), "description": c.get("description", "")}
|
||||
for c in bp.get('characters', [])
|
||||
]
|
||||
|
||||
total_chapters = ls.get('chapters', '?')
|
||||
prompt = f"""
|
||||
ROLE: Fiction Writer
|
||||
TASK: Write Chapter {chap['chapter_number']}: {chap['title']}
|
||||
|
||||
METADATA:
|
||||
- GENRE: {genre}
|
||||
- FORMAT: {ls.get('label', 'Story')}
|
||||
- POSITION: Chapter {chap['chapter_number']} of {total_chapters} — calibrate narrative tension accordingly (early = setup/intrigue, middle = escalation, final third = payoff/climax)
|
||||
- PACING: {pacing} — see PACING_GUIDE below
|
||||
- TARGET_WORDS: ~{est_words} (write to this length; do not summarise to save space)
|
||||
- POV: {pov_char if pov_char else 'Protagonist'}
|
||||
|
||||
PACING_GUIDE:
|
||||
- 'Very Fast': Pure action/dialogue. Minimal description. Short punchy paragraphs.
|
||||
- 'Fast': Keep momentum. No lingering. Cut to the next beat quickly.
|
||||
- 'Standard': Balanced dialogue and description. Standard paragraph lengths.
|
||||
- 'Slow': Detailed, atmospheric. Linger on emotion and environment.
|
||||
- 'Very Slow': Deep introspection. Heavy sensory immersion. Slow burn tension.
|
||||
|
||||
STYLE_GUIDE:
|
||||
{style_block}
|
||||
|
||||
AUTHOR_VOICE:
|
||||
{persona_info}
|
||||
|
||||
INSTRUCTIONS:
|
||||
- Start with the Chapter Header formatted as Markdown H1 (e.g. '# Chapter X: Title'). Follow the 'Formatting Rules' for the header style.
|
||||
|
||||
- SENSORY ANCHORING: Start scenes by establishing Who, Where, and When immediately.
|
||||
- DEEP POV: Immerse the reader in the POV character's immediate experience. Filter descriptions through their specific worldview and emotional state.
|
||||
- SHOW, DON'T TELL: Focus on immediate action and internal reaction. Don't summarize feelings; show the physical manifestation of them.
|
||||
- CAUSALITY: Ensure events follow a "Because of X, Y happened" logic, not just "And then X, and then Y".
|
||||
- STAGING: When characters enter, describe their entrance. Don't let them just "appear" in dialogue.
|
||||
- SENSORY DETAILS: Use specific sensory details sparingly to ground the scene. Avoid stacking adjectives (e.g. "crisp white blouses, sharp legal briefs").
|
||||
- ACTIVE VOICE: Use active voice. Subject -> Verb -> Object. Avoid "was/were" constructions.
|
||||
- STRONG VERBS: Delete adverbs. Use specific verbs (e.g. "trudged" instead of "walked slowly").
|
||||
- NO INFO-DUMPS: Weave backstory into dialogue or action. Do not stop the story to explain history.
|
||||
- AVOID CLICHÉS: Avoid common AI tropes (e.g., 'shiver down spine', 'palpable tension', 'unspoken agreement', 'testament to', 'tapestry of', 'azure', 'cerulean').
|
||||
- MAINTAIN CONTINUITY: Pay close attention to the PREVIOUS CONTEXT. Characters must NOT know things that haven't happened yet or haven't been revealed to them.
|
||||
- CHARACTER INTERACTIONS: If characters are meeting for the first time in the summary, treat them as strangers.
|
||||
- SENTENCE VARIETY: Avoid repetitive sentence structures (e.g. starting multiple sentences with "He" or "She"). Vary sentence length to create rhythm.
|
||||
- GENRE CONSISTENCY: Ensure all introductions of characters, places, items, or actions are strictly appropriate for the {genre} genre. Avoid anachronisms or tonal clashes.
|
||||
- DIALOGUE VOICE: Every character speaks with their own distinct voice (see CHARACTER TRACKING for speech styles). No two characters may sound the same. Vary sentence length, vocabulary, and register per character.
|
||||
- CHAPTER HOOK: End this chapter with unresolved tension — a decision pending, a threat imminent, or a question unanswered.{f" Seed subtle anticipation for the next scene: '{next_chapter_hint}'." if next_chapter_hint else " Do not neatly resolve all threads."}
|
||||
|
||||
QUALITY_CRITERIA:
|
||||
1. ENGAGEMENT & TENSION: Grip the reader. Ensure conflict/tension in every scene.
|
||||
2. SCENE EXECUTION: Flesh out the middle. Avoid summarizing key moments.
|
||||
3. VOICE & TONE: Distinct narrative voice matching the genre.
|
||||
4. SENSORY IMMERSION: Engage all five senses.
|
||||
5. SHOW, DON'T TELL: Show emotions through physical reactions and subtext.
|
||||
6. CHARACTER AGENCY: Characters must drive the plot through active choices.
|
||||
7. PACING: Avoid rushing. Ensure the ending lands with impact.
|
||||
8. GENRE APPROPRIATENESS: Introductions of characters, places, items, or actions must be consistent with {genre} conventions.
|
||||
9. DIALOGUE AUTHENTICITY: Characters must sound distinct. Use subtext. Avoid "on-the-nose" dialogue.
|
||||
10. PLOT RELEVANCE: Every scene must advance the plot or character arcs. No filler.
|
||||
11. STAGING & FLOW: Characters must enter and exit physically. Paragraphs must transition logically.
|
||||
12. PROSE DYNAMICS: Vary sentence length. Use strong verbs. Avoid passive voice.
|
||||
13. CLARITY: Ensure sentences are clear and readable. Avoid convoluted phrasing.
|
||||
|
||||
CONTEXT:
|
||||
- STORY_SO_FAR: {prev_sum}
|
||||
{prev_context_block}
|
||||
- CHARACTERS: {json.dumps(chars_for_writer)}
|
||||
{char_visuals}
|
||||
- SCENE_BEATS: {json.dumps(chap['beats'])}
|
||||
|
||||
OUTPUT: Markdown text.
|
||||
"""
|
||||
current_text = ""
|
||||
try:
|
||||
resp_draft = ai_models.model_writer.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_writer.name, resp_draft.usage_metadata)
|
||||
current_text = resp_draft.text
|
||||
draft_words = len(current_text.split()) if current_text else 0
|
||||
utils.log("WRITER", f" -> Draft: {draft_words:,} words (target: ~{est_words})")
|
||||
except Exception as e:
|
||||
utils.log("WRITER", f"⚠️ Failed Ch {chap['chapter_number']}: {e}")
|
||||
return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}"
|
||||
|
||||
max_attempts = 5
|
||||
SCORE_AUTO_ACCEPT = 8
|
||||
SCORE_PASSING = 7
|
||||
SCORE_REWRITE_THRESHOLD = 6
|
||||
|
||||
best_score = 0
|
||||
best_text = current_text
|
||||
past_critiques = []
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
utils.log("WRITER", f" -> Evaluating Ch {chap['chapter_number']} (Attempt {attempt}/{max_attempts})...")
|
||||
score, critique = evaluate_chapter_quality(current_text, chap['title'], meta.get('genre', 'Fiction'), ai_models.model_writer, folder)
|
||||
|
||||
past_critiques.append(f"Attempt {attempt}: {critique}")
|
||||
|
||||
if "Evaluation error" in critique:
|
||||
utils.log("WRITER", f" ⚠️ {critique}. Keeping current draft.")
|
||||
if best_score == 0: best_text = current_text
|
||||
break
|
||||
|
||||
utils.log("WRITER", f" Score: {score}/10. Critique: {critique}")
|
||||
|
||||
if score >= SCORE_AUTO_ACCEPT:
|
||||
utils.log("WRITER", " 🌟 Auto-Accept threshold met.")
|
||||
return current_text
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_text = current_text
|
||||
|
||||
if attempt == max_attempts:
|
||||
if best_score >= SCORE_PASSING:
|
||||
utils.log("WRITER", f" ✅ Max attempts reached. Accepting best score ({best_score}).")
|
||||
return best_text
|
||||
else:
|
||||
utils.log("WRITER", f" ⚠️ Quality low ({best_score}/{SCORE_PASSING}) but max attempts reached. Proceeding.")
|
||||
return best_text
|
||||
|
||||
if score < SCORE_REWRITE_THRESHOLD:
|
||||
utils.log("WRITER", f" -> Score {score} < {SCORE_REWRITE_THRESHOLD}. Triggering FULL REWRITE (Fresh Draft)...")
|
||||
|
||||
full_rewrite_prompt = prompt + f"""
|
||||
|
||||
[SYSTEM ALERT: QUALITY CHECK FAILED]
|
||||
The previous draft was rejected.
|
||||
CRITIQUE: {critique}
|
||||
|
||||
NEW TASK: Discard the previous attempt. Write a FRESH version of the chapter that addresses the critique above.
|
||||
"""
|
||||
|
||||
try:
|
||||
resp_rewrite = ai_models.model_logic.generate_content(full_rewrite_prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, resp_rewrite.usage_metadata)
|
||||
current_text = resp_rewrite.text
|
||||
continue
|
||||
except Exception as e:
|
||||
utils.log("WRITER", f"Full rewrite failed: {e}. Falling back to refinement.")
|
||||
|
||||
utils.log("WRITER", f" -> Refining Ch {chap['chapter_number']} based on feedback...")
|
||||
|
||||
guidelines = get_style_guidelines()
|
||||
fw_list = '", "'.join(guidelines['filter_words'])
|
||||
|
||||
history_str = "\n".join(past_critiques[-3:-1]) if len(past_critiques) > 1 else "None"
|
||||
|
||||
refine_prompt = f"""
|
||||
ROLE: Automated Editor
|
||||
TASK: Rewrite the draft chapter to address the critique. Preserve the narrative content and approximate word count.
|
||||
|
||||
CURRENT_CRITIQUE:
|
||||
{critique}
|
||||
|
||||
PREVIOUS_ATTEMPTS (context only):
|
||||
{history_str}
|
||||
|
||||
HARD_CONSTRAINTS:
|
||||
- TARGET_WORDS: ~{est_words} words (aim for this; ±20% is acceptable if the scene genuinely demands it — but do not condense beats to save space)
|
||||
- BEATS MUST BE COVERED: {json.dumps(chap.get('beats', []))}
|
||||
- SUMMARY CONTEXT: {prev_sum[:1500]}
|
||||
|
||||
AUTHOR_VOICE:
|
||||
{persona_info}
|
||||
|
||||
STYLE:
|
||||
{style_block}
|
||||
{char_visuals}
|
||||
|
||||
PROSE_RULES (fix each one found in the draft):
|
||||
1. FILTER_REMOVAL: Remove filter words [{fw_list}] — rewrite to show the sensation directly.
|
||||
2. VARIETY: No two consecutive sentences starting with the same word or pronoun.
|
||||
3. SUBTEXT: Dialogue must imply meaning — not state it outright.
|
||||
4. TONE: Match {meta.get('genre', 'Fiction')} conventions throughout.
|
||||
5. ENVIRONMENT: Characters interact with their physical space.
|
||||
6. NO_SUMMARY_MODE: Dramatise key moments — do not skip or summarise them.
|
||||
7. ACTIVE_VOICE: Replace 'was/were + verb-ing' constructions with active alternatives.
|
||||
8. SHOWING: Render emotion through physical reactions, not labels.
|
||||
9. STAGING: Characters must enter and exit physically — no teleporting.
|
||||
10. CLARITY: Prefer simple sentence structures over convoluted ones.
|
||||
|
||||
DRAFT_TO_REWRITE:
|
||||
{current_text}
|
||||
|
||||
PREVIOUS_CHAPTER_ENDING (maintain continuity):
|
||||
{prev_context_block}
|
||||
|
||||
OUTPUT: Complete polished chapter in Markdown. Include the chapter header. Same approximate length as the draft.
|
||||
"""
|
||||
try:
|
||||
resp_refine = ai_models.model_writer.generate_content(refine_prompt)
|
||||
utils.log_usage(folder, ai_models.model_writer.name, resp_refine.usage_metadata)
|
||||
current_text = resp_refine.text
|
||||
except Exception as e:
|
||||
utils.log("WRITER", f"Refinement failed: {e}")
|
||||
return best_text
|
||||
|
||||
return best_text
|
||||
@@ -7,8 +7,8 @@
|
||||
<p class="text-muted">System management and user administration.</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<a href="{{ url_for('admin_spend_report') }}" class="btn btn-outline-primary me-2"><i class="fas fa-chart-line me-2"></i>Spend Report</a>
|
||||
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary">Back to Dashboard</a>
|
||||
<a href="{{ url_for('admin.admin_spend_report') }}" class="btn btn-outline-primary me-2"><i class="fas fa-chart-line me-2"></i>Spend Report</a>
|
||||
<a href="{{ url_for('project.index') }}" class="btn btn-outline-secondary">Back to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<td>
|
||||
{% if u.id != current_user.id %}
|
||||
<form action="/admin/user/{{ u.id }}/delete" method="POST" onsubmit="return confirm('Delete user {{ u.username }} and ALL their projects? This cannot be undone.');">
|
||||
<a href="{{ url_for('impersonate_user', user_id=u.id) }}" class="btn btn-sm btn-outline-dark me-1" title="Impersonate User">
|
||||
<a href="{{ url_for('admin.impersonate_user', user_id=u.id) }}" class="btn btn-sm btn-outline-dark me-1" title="Impersonate User">
|
||||
<i class="fas fa-user-secret"></i>
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-danger" title="Delete User"><i class="fas fa-trash"></i></button>
|
||||
@@ -67,7 +67,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">Manage global AI writing rules and banned words.</p>
|
||||
<a href="{{ url_for('admin_style_guidelines') }}" class="btn btn-outline-primary w-100"><i class="fas fa-spell-check me-2"></i>Edit Style Guidelines</a>
|
||||
<a href="{{ url_for('admin.admin_style_guidelines') }}" class="btn btn-outline-primary w-100"><i class="fas fa-spell-check me-2"></i>Edit Style Guidelines</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<p class="text-muted">Aggregate cost analysis per user.</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-secondary">Back to Admin</a>
|
||||
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-outline-secondary">Back to Admin</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-spell-check me-2 text-primary"></i>Style Guidelines</h2>
|
||||
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-secondary">Back to Admin</a>
|
||||
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-outline-secondary">Back to Admin</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
@@ -36,7 +36,7 @@
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-save me-2"></i>Save Guidelines
|
||||
</button>
|
||||
<button type="submit" formaction="{{ url_for('optimize_models') }}" class="btn btn-outline-info w-100 mt-2">
|
||||
<button type="submit" formaction="{{ url_for('admin.optimize_models') }}" class="btn btn-outline-info w-100 mt-2">
|
||||
<i class="fas fa-magic me-2"></i>Auto-Refresh with AI
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
{% if session.get('original_admin_id') %}
|
||||
<div class="bg-danger text-white text-center py-2 shadow-sm" style="position: sticky; top: 0; z-index: 1050;">
|
||||
<strong><i class="fas fa-user-secret me-2"></i>Viewing site as {{ current_user.username }}</strong>
|
||||
<a href="{{ url_for('stop_impersonate') }}" class="btn btn-sm btn-light ms-3 text-danger fw-bold">Stop Impersonating</a>
|
||||
<a href="{{ url_for('admin.stop_impersonate') }}" class="btn btn-sm btn-light ms-3 text-danger fw-bold">Stop Impersonating</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-search me-2"></i>Consistency Report</h2>
|
||||
<a href="{{ url_for('view_run', id=run.id) }}" class="btn btn-outline-secondary">Back to Run</a>
|
||||
<a href="{{ url_for('run.view_run', id=run.id) }}" class="btn btn-outline-secondary">Back to Run</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<h4 class="mb-0">{% if name %}Edit Persona: {{ name }}{% else %}New Persona{% endif %}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('save_persona') }}" method="POST">
|
||||
<form action="{{ url_for('persona.save_persona') }}" method="POST">
|
||||
<input type="hidden" name="old_name" value="{{ name }}">
|
||||
|
||||
<div class="mb-3">
|
||||
@@ -72,7 +72,7 @@
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('list_personas') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
<a href="{{ url_for('persona.list_personas') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Save Persona</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-users me-2"></i>Author Personas</h2>
|
||||
<a href="{{ url_for('new_persona') }}" class="btn btn-primary"><i class="fas fa-plus me-2"></i>Create New Persona</a>
|
||||
<a href="{{ url_for('persona.new_persona') }}" class="btn btn-primary"><i class="fas fa-plus me-2"></i>Create New Persona</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
@@ -16,8 +16,8 @@
|
||||
<p class="card-text small">{{ p.bio[:150] }}...</p>
|
||||
</div>
|
||||
<div class="card-footer bg-white border-top-0 d-flex justify-content-between">
|
||||
<a href="{{ url_for('edit_persona', name=name) }}" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||
<form action="{{ url_for('delete_persona', name=name) }}" method="POST" onsubmit="return confirm('Delete this persona?');">
|
||||
<a href="{{ url_for('persona.edit_persona', name=name) }}" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||
<form action="{{ url_for('persona.delete_persona', name=name) }}" method="POST" onsubmit="return confirm('Delete this persona?');">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
</td>
|
||||
<td>${{ "%.4f"|format(r.cost) }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('view_run', id=r.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
<a href="{{ url_for('run.view_run', id=r.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
{{ 'View Active' if active_run and r.id == active_run.id and active_run.status in ['running', 'queued'] else 'View' }}
|
||||
</a>
|
||||
{% if r.status in ['failed', 'cancelled', 'interrupted'] %}
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
<small class="text-muted">Run #{{ run.id }}</small>
|
||||
</div>
|
||||
<div>
|
||||
<form action="{{ url_for('sync_book_metadata', run_id=run.id, book_folder=book_folder) }}" method="POST" class="d-inline me-2" onsubmit="return confirm('This will re-scan your manuscript to update the character list and author persona. Continue?');">
|
||||
<form action="{{ url_for('run.sync_book_metadata', run_id=run.id, book_folder=book_folder) }}" method="POST" class="d-inline me-2" onsubmit="return confirm('This will re-scan your manuscript to update the character list and author persona. Continue?');">
|
||||
<button type="submit" class="btn btn-outline-info" data-bs-toggle="tooltip" title="Scans your manual edits to update the character database and author writing style. Use this after making significant edits.">
|
||||
<i class="fas fa-sync me-2"></i>Sync Metadata
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{ url_for('view_run', id=run.id) }}" class="btn btn-outline-secondary">Back to Run</a>
|
||||
<a href="{{ url_for('run.view_run', id=run.id) }}" class="btn btn-outline-secondary">Back to Run</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2><i class="fas fa-book me-2"></i>Run #{{ run.id }}</h2>
|
||||
<p class="text-muted mb-0">Project: <a href="{{ url_for('view_project', id=run.project_id) }}">{{ run.project.name }}</a></p>
|
||||
<p class="text-muted mb-0">Project: <a href="{{ url_for('project.view_project', id=run.project_id) }}">{{ run.project.name }}</a></p>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-outline-primary me-2" type="button" data-bs-toggle="collapse" data-bs-target="#bibleCollapse" aria-expanded="false" aria-controls="bibleCollapse">
|
||||
@@ -13,7 +13,7 @@
|
||||
<button class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#modifyRunModal" data-bs-toggle="tooltip" title="Create a new run based on this one, but with different instructions (e.g. 'Make it darker').">
|
||||
<i class="fas fa-pen-fancy me-2"></i>Modify & Re-run
|
||||
</button>
|
||||
<a href="{{ url_for('view_project', id=run.project_id) }}" class="btn btn-outline-secondary">Back to Project</a>
|
||||
<a href="{{ url_for('project.view_project', id=run.project_id) }}" class="btn btn-outline-secondary">Back to Project</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="text-center">
|
||||
{% if book.cover %}
|
||||
<img src="{{ url_for('download_artifact', run_id=run.id, file=book.cover) }}" class="img-fluid rounded shadow-sm mb-3" alt="Book Cover" style="max-height: 400px;">
|
||||
<img src="{{ url_for('run.download_artifact', run_id=run.id, file=book.cover) }}" class="img-fluid rounded shadow-sm mb-3" alt="Book Cover" style="max-height: 400px;">
|
||||
{% else %}
|
||||
<div class="alert alert-secondary py-5">
|
||||
<i class="fas fa-image fa-3x mb-3"></i><br>No cover.
|
||||
@@ -132,7 +132,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% if loop.first %}
|
||||
<form action="{{ url_for('regenerate_artifacts', run_id=run.id) }}" method="POST" class="mt-2">
|
||||
<form action="{{ url_for('run.regenerate_artifacts', run_id=run.id) }}" method="POST" class="mt-2">
|
||||
<textarea name="feedback" class="form-control mb-2 form-control-sm" rows="1" placeholder="Cover Feedback..."></textarea>
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary w-100">
|
||||
<i class="fas fa-sync me-2"></i>Regenerate All
|
||||
@@ -156,7 +156,7 @@
|
||||
<h6 class="fw-bold">Artifacts</h6>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for art in book.artifacts %}
|
||||
<a href="{{ url_for('download_artifact', run_id=run.id, file=art.path) }}" class="btn btn-sm btn-outline-success">
|
||||
<a href="{{ url_for('run.download_artifact', run_id=run.id, file=art.path) }}" class="btn btn-sm btn-outline-success">
|
||||
<i class="fas fa-download me-1"></i> {{ art.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
@@ -164,10 +164,10 @@
|
||||
{% endfor %}
|
||||
|
||||
<div class="mt-3">
|
||||
<a href="{{ url_for('read_book', run_id=run.id, book_folder=book.folder) }}" class="btn btn-primary">
|
||||
<a href="{{ url_for('run.read_book', run_id=run.id, book_folder=book.folder) }}" class="btn btn-primary">
|
||||
<i class="fas fa-book-reader me-2"></i>Read & Edit
|
||||
</a>
|
||||
<a href="{{ url_for('check_consistency', run_id=run.id, book_folder=book.folder) }}" class="btn btn-outline-warning ms-2">
|
||||
<a href="{{ url_for('run.check_consistency', run_id=run.id, book_folder=book.folder) }}" class="btn btn-outline-warning ms-2">
|
||||
<i class="fas fa-search me-2"></i>Check Consistency
|
||||
</a>
|
||||
<button class="btn btn-warning ms-2" data-bs-toggle="modal" data-bs-target="#reviseBookModal{{ loop.index }}" title="Regenerate this book with changes, keeping others.">
|
||||
@@ -183,7 +183,7 @@
|
||||
<!-- Revise Book Modal -->
|
||||
<div class="modal fade" id="reviseBookModal{{ loop.index }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" action="{{ url_for('revise_book', run_id=run.id, book_folder=book.folder) }}" method="POST">
|
||||
<form class="modal-content" action="{{ url_for('project.revise_book', run_id=run.id, book_folder=book.folder) }}" method="POST">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Revise Book</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<p class="text-muted">AI Model Health, Selection Reasoning, and Availability.</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary me-2">Back to Dashboard</a>
|
||||
<form action="{{ url_for('optimize_models') }}" method="POST" class="d-inline" onsubmit="return confirm('This will re-analyze all available models. Continue?');">
|
||||
<a href="{{ url_for('project.index') }}" class="btn btn-outline-secondary me-2">Back to Dashboard</a>
|
||||
<form action="{{ url_for('admin.optimize_models') }}" method="POST" class="d-inline" onsubmit="return confirm('This will re-analyze all available models. Continue?');">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-sync me-2"></i>Refresh & Optimize
|
||||
</button>
|
||||
|
||||
0
web/__init__.py
Normal file
0
web/__init__.py
Normal file
106
web/app.py
Normal file
106
web/app.py
Normal file
@@ -0,0 +1,106 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from sqlalchemy import text
|
||||
from flask import Flask
|
||||
from flask_login import LoginManager
|
||||
from werkzeug.security import generate_password_hash
|
||||
from web.db import db, User, Run
|
||||
from web.tasks import huey
|
||||
from core import config
|
||||
|
||||
# Calculate paths relative to this file (web/app.py -> project root is two levels up)
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TEMPLATE_DIR = os.path.join(BASE_DIR, 'templates')
|
||||
|
||||
app = Flask(__name__, template_folder=TEMPLATE_DIR)
|
||||
app.url_map.strict_slashes = False
|
||||
app.config['SECRET_KEY'] = config.FLASK_SECRET
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(config.DATA_DIR, "bookapp.db")}'
|
||||
|
||||
db.init_app(app)
|
||||
|
||||
login_manager = LoginManager()
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.init_app(app)
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return db.session.get(User, int(user_id))
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
return dict(app_version=config.VERSION)
|
||||
|
||||
|
||||
# Register Blueprints
|
||||
from web.routes.auth import auth_bp
|
||||
from web.routes.project import project_bp
|
||||
from web.routes.run import run_bp
|
||||
from web.routes.persona import persona_bp
|
||||
from web.routes.admin import admin_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(project_bp)
|
||||
app.register_blueprint(run_bp)
|
||||
app.register_blueprint(persona_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
|
||||
# --- SETUP ---
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
# Auto-create Admin from Environment Variables (Docker/Portainer Setup)
|
||||
if config.ADMIN_USER and config.ADMIN_PASSWORD:
|
||||
admin = User.query.filter_by(username=config.ADMIN_USER).first()
|
||||
if not admin:
|
||||
print(f"🔐 System: Creating Admin User '{config.ADMIN_USER}' from environment variables.")
|
||||
admin = User(username=config.ADMIN_USER, password=generate_password_hash(config.ADMIN_PASSWORD, method='pbkdf2:sha256'), is_admin=True)
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
else:
|
||||
print(f"🔐 System: Syncing Admin User '{config.ADMIN_USER}' settings from environment.")
|
||||
if not admin.is_admin: admin.is_admin = True
|
||||
admin.password = generate_password_hash(config.ADMIN_PASSWORD, method='pbkdf2:sha256')
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
elif not User.query.filter_by(is_admin=True).first():
|
||||
print("ℹ️ System: No Admin credentials found in environment variables. Admin account not created.")
|
||||
|
||||
# Migration: Add 'progress' column if missing
|
||||
try:
|
||||
with db.engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE run ADD COLUMN progress INTEGER DEFAULT 0"))
|
||||
conn.commit()
|
||||
print("✅ System: Added 'progress' column to Run table.")
|
||||
except: pass
|
||||
|
||||
# Reset stuck runs on startup
|
||||
try:
|
||||
stuck_runs = Run.query.filter_by(status='running').all()
|
||||
if stuck_runs:
|
||||
print(f"⚠️ System: Found {len(stuck_runs)} stuck runs. Resetting to 'failed'.")
|
||||
for r in stuck_runs:
|
||||
r.status = 'failed'
|
||||
r.end_time = datetime.utcnow()
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
print(f"⚠️ System: Failed to clean up stuck runs: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import threading
|
||||
from huey.contrib.mini import MiniHuey
|
||||
|
||||
# Start Huey consumer in background thread
|
||||
def run_huey():
|
||||
from huey.consumer import Consumer
|
||||
consumer = Consumer(huey, workers=1, worker_type='thread', loglevel=20)
|
||||
consumer.run()
|
||||
|
||||
t = threading.Thread(target=run_huey, daemon=True)
|
||||
t.start()
|
||||
|
||||
app.run(host='0.0.0.0', port=7070, debug=False)
|
||||
@@ -4,45 +4,47 @@ from datetime import datetime
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(150), unique=True, nullable=False)
|
||||
password = db.Column(db.String(150), nullable=False)
|
||||
api_key = db.Column(db.String(200), nullable=True) # Optional: User-specific Gemini Key
|
||||
api_key = db.Column(db.String(200), nullable=True)
|
||||
total_spend = db.Column(db.Float, default=0.0)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
|
||||
|
||||
class Project(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
folder_path = db.Column(db.String(300), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
|
||||
runs = db.relationship('Run', backref='project', lazy=True, cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Run(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False)
|
||||
status = db.Column(db.String(50), default="queued") # queued, running, completed, failed
|
||||
status = db.Column(db.String(50), default="queued")
|
||||
start_time = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
end_time = db.Column(db.DateTime, nullable=True)
|
||||
log_file = db.Column(db.String(300), nullable=True)
|
||||
cost = db.Column(db.Float, default=0.0)
|
||||
progress = db.Column(db.Integer, default=0)
|
||||
|
||||
# Relationships
|
||||
|
||||
logs = db.relationship('LogEntry', backref='run', lazy=True, cascade="all, delete-orphan")
|
||||
|
||||
|
||||
def duration(self):
|
||||
if self.end_time and self.start_time:
|
||||
return str(self.end_time - self.start_time).split('.')[0]
|
||||
return "Running..."
|
||||
|
||||
|
||||
class LogEntry(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
run_id = db.Column(db.Integer, db.ForeignKey('run.id'), nullable=False)
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
phase = db.Column(db.String(50))
|
||||
message = db.Column(db.Text)
|
||||
message = db.Column(db.Text)
|
||||
25
web/helpers.py
Normal file
25
web/helpers.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from functools import wraps
|
||||
from urllib.parse import urlparse, urljoin
|
||||
from flask import redirect, url_for, flash, request
|
||||
from flask_login import current_user
|
||||
from web.db import Run
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated or not current_user.is_admin:
|
||||
flash("Admin access required.")
|
||||
return redirect(url_for('project.index'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def is_project_locked(project_id):
|
||||
return Run.query.filter_by(project_id=project_id, status='completed').count() > 0
|
||||
|
||||
|
||||
def is_safe_url(target):
|
||||
ref_url = urlparse(request.host_url)
|
||||
test_url = urlparse(urljoin(request.host_url, target))
|
||||
return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc
|
||||
0
web/routes/__init__.py
Normal file
0
web/routes/__init__.py
Normal file
226
web/routes/admin.py
Normal file
226
web/routes/admin.py
Normal file
@@ -0,0 +1,226 @@
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
|
||||
from flask_login import login_required, login_user, current_user
|
||||
from sqlalchemy import func
|
||||
from web.db import db, User, Project, Run
|
||||
from web.helpers import admin_required
|
||||
from core import config, utils
|
||||
from ai import models as ai_models
|
||||
from ai import setup as ai_setup
|
||||
from story import style_persona, bible_tracker
|
||||
|
||||
admin_bp = Blueprint('admin', __name__)
|
||||
|
||||
|
||||
@admin_bp.route('/admin')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_dashboard():
|
||||
users = User.query.all()
|
||||
projects = Project.query.all()
|
||||
return render_template('admin_dashboard.html', users=users, projects=projects)
|
||||
|
||||
|
||||
@admin_bp.route('/admin/user/<int:user_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_delete_user(user_id):
|
||||
if user_id == current_user.id:
|
||||
flash("Cannot delete yourself.")
|
||||
return redirect(url_for('admin.admin_dashboard'))
|
||||
|
||||
user = db.session.get(User, user_id)
|
||||
if user:
|
||||
user_path = os.path.join(config.DATA_DIR, "users", str(user.id))
|
||||
if os.path.exists(user_path):
|
||||
try: shutil.rmtree(user_path)
|
||||
except: pass
|
||||
|
||||
projects = Project.query.filter_by(user_id=user.id).all()
|
||||
for p in projects:
|
||||
db.session.delete(p)
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
flash(f"User {user.username} deleted.")
|
||||
return redirect(url_for('admin.admin_dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/admin/project/<int:project_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_delete_project(project_id):
|
||||
proj = db.session.get(Project, project_id)
|
||||
if proj:
|
||||
if os.path.exists(proj.folder_path):
|
||||
try: shutil.rmtree(proj.folder_path)
|
||||
except: pass
|
||||
db.session.delete(proj)
|
||||
db.session.commit()
|
||||
flash(f"Project {proj.name} deleted.")
|
||||
return redirect(url_for('admin.admin_dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/admin/reset', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_factory_reset():
|
||||
projects = Project.query.all()
|
||||
for p in projects:
|
||||
if os.path.exists(p.folder_path):
|
||||
try: shutil.rmtree(p.folder_path)
|
||||
except: pass
|
||||
db.session.delete(p)
|
||||
|
||||
users = User.query.filter(User.id != current_user.id).all()
|
||||
for u in users:
|
||||
user_path = os.path.join(config.DATA_DIR, "users", str(u.id))
|
||||
if os.path.exists(user_path):
|
||||
try: shutil.rmtree(user_path)
|
||||
except: pass
|
||||
db.session.delete(u)
|
||||
|
||||
if os.path.exists(config.PERSONAS_FILE):
|
||||
try: os.remove(config.PERSONAS_FILE)
|
||||
except: pass
|
||||
utils.create_default_personas()
|
||||
|
||||
db.session.commit()
|
||||
flash("Factory Reset Complete. All other users and projects have been wiped.")
|
||||
return redirect(url_for('admin.admin_dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/admin/spend')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_spend_report():
|
||||
days = request.args.get('days', 30, type=int)
|
||||
|
||||
if days > 0:
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
else:
|
||||
start_date = datetime.min
|
||||
|
||||
results = db.session.query(
|
||||
User.username,
|
||||
func.count(Run.id),
|
||||
func.sum(Run.cost)
|
||||
).join(Project, Project.user_id == User.id)\
|
||||
.join(Run, Run.project_id == Project.id)\
|
||||
.filter(Run.start_time >= start_date)\
|
||||
.group_by(User.id, User.username).all()
|
||||
|
||||
report = []
|
||||
total_period_spend = 0.0
|
||||
for r in results:
|
||||
cost = r[2] if r[2] else 0.0
|
||||
report.append({"username": r[0], "runs": r[1], "cost": cost})
|
||||
total_period_spend += cost
|
||||
|
||||
return render_template('admin_spend.html', report=report, days=days, total=total_period_spend)
|
||||
|
||||
|
||||
@admin_bp.route('/admin/style', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_style_guidelines():
|
||||
path = os.path.join(config.DATA_DIR, "style_guidelines.json")
|
||||
|
||||
if request.method == 'POST':
|
||||
ai_isms_raw = request.form.get('ai_isms', '')
|
||||
filter_words_raw = request.form.get('filter_words', '')
|
||||
|
||||
data = {
|
||||
"ai_isms": [x.strip() for x in ai_isms_raw.split('\n') if x.strip()],
|
||||
"filter_words": [x.strip() for x in filter_words_raw.split('\n') if x.strip()]
|
||||
}
|
||||
|
||||
with open(path, 'w') as f: json.dump(data, f, indent=2)
|
||||
flash("Style Guidelines updated successfully.")
|
||||
return redirect(url_for('admin.admin_style_guidelines'))
|
||||
|
||||
data = style_persona.get_style_guidelines()
|
||||
return render_template('admin_style.html', data=data)
|
||||
|
||||
|
||||
@admin_bp.route('/admin/impersonate/<int:user_id>')
|
||||
@login_required
|
||||
@admin_required
|
||||
def impersonate_user(user_id):
|
||||
if user_id == current_user.id:
|
||||
flash("Cannot impersonate yourself.")
|
||||
return redirect(url_for('admin.admin_dashboard'))
|
||||
|
||||
user = db.session.get(User, user_id)
|
||||
if user:
|
||||
session['original_admin_id'] = current_user.id
|
||||
login_user(user)
|
||||
flash(f"Now viewing as {user.username}")
|
||||
return redirect(url_for('project.index'))
|
||||
return redirect(url_for('admin.admin_dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/admin/stop_impersonate')
|
||||
@login_required
|
||||
def stop_impersonate():
|
||||
admin_id = session.get('original_admin_id')
|
||||
if admin_id:
|
||||
admin = db.session.get(User, admin_id)
|
||||
if admin:
|
||||
login_user(admin)
|
||||
session.pop('original_admin_id', None)
|
||||
flash("Restored admin session.")
|
||||
return redirect(url_for('admin.admin_dashboard'))
|
||||
return redirect(url_for('project.index'))
|
||||
|
||||
|
||||
@admin_bp.route('/debug/routes')
|
||||
@login_required
|
||||
@admin_required
|
||||
def debug_routes():
|
||||
from flask import current_app
|
||||
output = []
|
||||
for rule in current_app.url_map.iter_rules():
|
||||
methods = ','.join(rule.methods)
|
||||
rule_str = str(rule).replace('<', '[').replace('>', ']')
|
||||
line = "{:50s} {:20s} {}".format(rule.endpoint, methods, rule_str)
|
||||
output.append(line)
|
||||
return "<pre>" + "\n".join(output) + "</pre>"
|
||||
|
||||
|
||||
@admin_bp.route('/system/optimize_models', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def optimize_models():
|
||||
try:
|
||||
ai_setup.init_models(force=True)
|
||||
|
||||
if ai_models.model_logic:
|
||||
style_persona.refresh_style_guidelines(ai_models.model_logic)
|
||||
|
||||
flash("AI Models refreshed and Style Guidelines updated.")
|
||||
except Exception as e:
|
||||
flash(f"Error refreshing models: {e}")
|
||||
|
||||
return redirect(request.referrer or url_for('project.index'))
|
||||
|
||||
|
||||
@admin_bp.route('/system/status')
|
||||
@login_required
|
||||
def system_status():
|
||||
models_info = {}
|
||||
cache_data = {}
|
||||
|
||||
cache_path = os.path.join(config.DATA_DIR, "model_cache.json")
|
||||
if os.path.exists(cache_path):
|
||||
try:
|
||||
with open(cache_path, 'r') as f:
|
||||
cache_data = json.load(f)
|
||||
models_info = cache_data.get('models', {})
|
||||
except: pass
|
||||
|
||||
return render_template('system_status.html', models=models_info, cache=cache_data, datetime=datetime,
|
||||
image_model=ai_models.image_model_name, image_source=ai_models.image_model_source)
|
||||
57
web/routes/auth.py
Normal file
57
web/routes/auth.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
|
||||
from flask_login import login_user, login_required, logout_user, current_user
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from web.db import db, User
|
||||
from web.helpers import is_safe_url
|
||||
from core import config
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user and check_password_hash(user.password, password):
|
||||
login_user(user)
|
||||
next_page = request.args.get('next')
|
||||
if not next_page or not is_safe_url(next_page):
|
||||
next_page = url_for('project.index')
|
||||
return redirect(next_page)
|
||||
if user and user.is_admin:
|
||||
print(f"⚠️ System: Admin login failed for '{username}'. Password hash mismatch.")
|
||||
flash('Invalid credentials')
|
||||
return render_template('login.html')
|
||||
|
||||
|
||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash('Username exists')
|
||||
return redirect(url_for('auth.register'))
|
||||
|
||||
new_user = User(username=username, password=generate_password_hash(password, method='pbkdf2:sha256'))
|
||||
if config.ADMIN_USER and username == config.ADMIN_USER:
|
||||
new_user.is_admin = True
|
||||
try:
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
login_user(new_user)
|
||||
return redirect(url_for('project.index'))
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
flash('Username exists')
|
||||
return redirect(url_for('auth.register'))
|
||||
return render_template('register.html')
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for('auth.login'))
|
||||
135
web/routes/persona.py
Normal file
135
web/routes/persona.py
Normal file
@@ -0,0 +1,135 @@
|
||||
import os
|
||||
import json
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required
|
||||
from core import config, utils
|
||||
from ai import models as ai_models
|
||||
from ai import setup as ai_setup
|
||||
|
||||
persona_bp = Blueprint('persona', __name__)
|
||||
|
||||
|
||||
@persona_bp.route('/personas')
|
||||
@login_required
|
||||
def list_personas():
|
||||
personas = {}
|
||||
if os.path.exists(config.PERSONAS_FILE):
|
||||
try:
|
||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
||||
except: pass
|
||||
return render_template('personas.html', personas=personas)
|
||||
|
||||
|
||||
@persona_bp.route('/persona/new')
|
||||
@login_required
|
||||
def new_persona():
|
||||
return render_template('persona_edit.html', persona={}, name="")
|
||||
|
||||
|
||||
@persona_bp.route('/persona/<string:name>')
|
||||
@login_required
|
||||
def edit_persona(name):
|
||||
personas = {}
|
||||
if os.path.exists(config.PERSONAS_FILE):
|
||||
try:
|
||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
||||
except: pass
|
||||
|
||||
persona = personas.get(name)
|
||||
if not persona:
|
||||
flash(f"Persona '{name}' not found.")
|
||||
return redirect(url_for('persona.list_personas'))
|
||||
|
||||
return render_template('persona_edit.html', persona=persona, name=name)
|
||||
|
||||
|
||||
@persona_bp.route('/persona/save', methods=['POST'])
|
||||
@login_required
|
||||
def save_persona():
|
||||
old_name = request.form.get('old_name')
|
||||
name = request.form.get('name')
|
||||
|
||||
if not name:
|
||||
flash("Persona name is required.")
|
||||
return redirect(url_for('persona.list_personas'))
|
||||
|
||||
personas = {}
|
||||
if os.path.exists(config.PERSONAS_FILE):
|
||||
try:
|
||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
||||
except: pass
|
||||
|
||||
if old_name and old_name != name and old_name in personas:
|
||||
del personas[old_name]
|
||||
|
||||
persona = {
|
||||
"name": name,
|
||||
"bio": request.form.get('bio'),
|
||||
"age": request.form.get('age'),
|
||||
"gender": request.form.get('gender'),
|
||||
"race": request.form.get('race'),
|
||||
"nationality": request.form.get('nationality'),
|
||||
"language": request.form.get('language'),
|
||||
"sample_text": request.form.get('sample_text'),
|
||||
"voice_keywords": request.form.get('voice_keywords'),
|
||||
"style_inspirations": request.form.get('style_inspirations')
|
||||
}
|
||||
|
||||
personas[name] = persona
|
||||
|
||||
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2)
|
||||
|
||||
flash(f"Persona '{name}' saved.")
|
||||
return redirect(url_for('persona.list_personas'))
|
||||
|
||||
|
||||
@persona_bp.route('/persona/delete/<string:name>', methods=['POST'])
|
||||
@login_required
|
||||
def delete_persona(name):
|
||||
personas = {}
|
||||
if os.path.exists(config.PERSONAS_FILE):
|
||||
try:
|
||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
||||
except: pass
|
||||
|
||||
if name in personas:
|
||||
del personas[name]
|
||||
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2)
|
||||
flash(f"Persona '{name}' deleted.")
|
||||
|
||||
return redirect(url_for('persona.list_personas'))
|
||||
|
||||
|
||||
@persona_bp.route('/persona/analyze', methods=['POST'])
|
||||
@login_required
|
||||
def analyze_persona():
|
||||
try: ai_setup.init_models()
|
||||
except: pass
|
||||
|
||||
if not ai_models.model_logic:
|
||||
return {"error": "AI models not initialized."}, 500
|
||||
|
||||
data = request.json
|
||||
sample = data.get('sample_text', '')
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Literary Analyst
|
||||
TASK: Create or analyze an Author Persona profile.
|
||||
|
||||
INPUT_DATA:
|
||||
- NAME: {data.get('name')}
|
||||
- DEMOGRAPHICS: Age: {data.get('age')} | Gender: {data.get('gender')} | Nationality: {data.get('nationality')}
|
||||
- SAMPLE_TEXT: {sample[:3000]}
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. BIO: Write a 2-3 sentence description of the writing style. If sample is provided, analyze it. If not, invent a style that fits the demographics/name.
|
||||
2. KEYWORDS: Comma-separated list of 3-5 adjectives describing the voice (e.g. Gritty, Whimsical, Sarcastic).
|
||||
3. INSPIRATIONS: Comma-separated list of 1-3 famous authors or genres that this style resembles.
|
||||
|
||||
OUTPUT_FORMAT (JSON): {{ "bio": "String", "voice_keywords": "String", "style_inspirations": "String" }}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
return json.loads(utils.clean_json(response.text))
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 500
|
||||
760
web/routes/project.py
Normal file
760
web/routes/project.py
Normal file
@@ -0,0 +1,760 @@
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from web.db import db, Project, Run
|
||||
from web.helpers import is_project_locked
|
||||
from core import config, utils
|
||||
from ai import models as ai_models
|
||||
from ai import setup as ai_setup
|
||||
from story import planner, bible_tracker
|
||||
from web.tasks import generate_book_task, refine_bible_task
|
||||
|
||||
project_bp = Blueprint('project', __name__)
|
||||
|
||||
|
||||
@project_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
projects = Project.query.filter_by(user_id=current_user.id).all()
|
||||
return render_template('dashboard.html', projects=projects, user=current_user)
|
||||
|
||||
|
||||
@project_bp.route('/project/setup', methods=['POST'])
|
||||
@login_required
|
||||
def project_setup_wizard():
|
||||
concept = request.form.get('concept')
|
||||
|
||||
try: ai_setup.init_models()
|
||||
except: pass
|
||||
|
||||
if not ai_models.model_logic:
|
||||
flash("AI models not initialized.")
|
||||
return redirect(url_for('project.index'))
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Publishing Analyst
|
||||
TASK: Suggest metadata for a story concept.
|
||||
|
||||
CONCEPT: {concept}
|
||||
|
||||
OUTPUT_FORMAT (JSON):
|
||||
{{
|
||||
"title": "String",
|
||||
"genre": "String",
|
||||
"target_audience": "String",
|
||||
"tone": "String",
|
||||
"length_category": "String (Select code: '01'=Chapter Book, '1'=Flash Fiction, '2'=Short Story, '2b'=Young Adult, '3'=Novella, '4'=Novel, '5'=Epic)",
|
||||
"estimated_chapters": Int,
|
||||
"estimated_word_count": "String (e.g. '75,000')",
|
||||
"include_prologue": Bool,
|
||||
"include_epilogue": Bool,
|
||||
"tropes": ["String"],
|
||||
"pov_style": "String",
|
||||
"time_period": "String",
|
||||
"spice": "String",
|
||||
"violence": "String",
|
||||
"is_series": Bool,
|
||||
"series_title": "String",
|
||||
"narrative_tense": "String",
|
||||
"language_style": "String",
|
||||
"dialogue_style": "String",
|
||||
"page_orientation": "Portrait|Landscape|Square",
|
||||
"formatting_rules": ["String (e.g. 'Chapter Headers: Number + Title')"],
|
||||
"author_bio": "String"
|
||||
}}
|
||||
"""
|
||||
|
||||
suggestions = {}
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
suggestions = json.loads(utils.clean_json(response.text))
|
||||
except Exception as e:
|
||||
flash(f"AI Analysis failed: {e}")
|
||||
suggestions = {"title": "New Project", "genre": "Fiction"}
|
||||
|
||||
personas = {}
|
||||
if os.path.exists(config.PERSONAS_FILE):
|
||||
try:
|
||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
||||
except: pass
|
||||
|
||||
return render_template('project_setup.html', s=suggestions, concept=concept, personas=personas, lengths=config.LENGTH_DEFINITIONS)
|
||||
|
||||
|
||||
@project_bp.route('/project/setup/refine', methods=['POST'])
|
||||
@login_required
|
||||
def project_setup_refine():
|
||||
concept = request.form.get('concept')
|
||||
instruction = request.form.get('refine_instruction')
|
||||
|
||||
current_state = {
|
||||
"title": request.form.get('title'),
|
||||
"genre": request.form.get('genre'),
|
||||
"target_audience": request.form.get('audience'),
|
||||
"tone": request.form.get('tone'),
|
||||
}
|
||||
|
||||
try: ai_setup.init_models()
|
||||
except: pass
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Publishing Analyst
|
||||
TASK: Refine project metadata based on user instruction.
|
||||
|
||||
INPUT_DATA:
|
||||
- ORIGINAL_CONCEPT: {concept}
|
||||
- CURRENT_TITLE: {current_state['title']}
|
||||
- INSTRUCTION: {instruction}
|
||||
|
||||
OUTPUT_FORMAT (JSON): Same structure as the initial analysis (title, genre, length_category, etc). Ensure length_category matches the word count.
|
||||
"""
|
||||
|
||||
suggestions = {}
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
suggestions = json.loads(utils.clean_json(response.text))
|
||||
except Exception as e:
|
||||
flash(f"Refinement failed: {e}")
|
||||
return redirect(url_for('project.index'))
|
||||
|
||||
personas = {}
|
||||
if os.path.exists(config.PERSONAS_FILE):
|
||||
try:
|
||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
||||
except: pass
|
||||
|
||||
return render_template('project_setup.html', s=suggestions, concept=concept, personas=personas, lengths=config.LENGTH_DEFINITIONS)
|
||||
|
||||
|
||||
@project_bp.route('/project/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_project_final():
|
||||
title = request.form.get('title')
|
||||
safe_title = utils.sanitize_filename(title)
|
||||
|
||||
user_dir = os.path.join(config.DATA_DIR, "users", str(current_user.id))
|
||||
os.makedirs(user_dir, exist_ok=True)
|
||||
|
||||
proj_path = os.path.join(user_dir, safe_title)
|
||||
if os.path.exists(proj_path):
|
||||
safe_title += f"_{int(datetime.utcnow().timestamp())}"
|
||||
proj_path = os.path.join(user_dir, safe_title)
|
||||
os.makedirs(proj_path, exist_ok=True)
|
||||
|
||||
length_cat = request.form.get('length_category')
|
||||
len_def = config.LENGTH_DEFINITIONS.get(length_cat, config.LENGTH_DEFINITIONS['4']).copy()
|
||||
|
||||
try: len_def['chapters'] = int(request.form.get('chapters'))
|
||||
except: pass
|
||||
len_def['words'] = request.form.get('words')
|
||||
len_def['include_prologue'] = 'include_prologue' in request.form
|
||||
len_def['include_epilogue'] = 'include_epilogue' in request.form
|
||||
|
||||
is_series = 'is_series' in request.form
|
||||
|
||||
style = {
|
||||
"tone": request.form.get('tone'),
|
||||
"pov_style": request.form.get('pov_style'),
|
||||
"time_period": request.form.get('time_period'),
|
||||
"spice": request.form.get('spice'),
|
||||
"violence": request.form.get('violence'),
|
||||
"narrative_tense": request.form.get('narrative_tense'),
|
||||
"language_style": request.form.get('language_style'),
|
||||
"dialogue_style": request.form.get('dialogue_style'),
|
||||
"page_orientation": request.form.get('page_orientation'),
|
||||
"tropes": [x.strip() for x in request.form.get('tropes', '').split(',') if x.strip()],
|
||||
"formatting_rules": [x.strip() for x in request.form.get('formatting_rules', '').split(',') if x.strip()]
|
||||
}
|
||||
|
||||
bible = {
|
||||
"project_metadata": {
|
||||
"title": title,
|
||||
"author": request.form.get('author'),
|
||||
"author_bio": request.form.get('author_bio'),
|
||||
"genre": request.form.get('genre'),
|
||||
"target_audience": request.form.get('audience'),
|
||||
"is_series": is_series,
|
||||
"length_settings": len_def,
|
||||
"style": style
|
||||
},
|
||||
"books": [],
|
||||
"characters": []
|
||||
}
|
||||
|
||||
count = 1
|
||||
if is_series:
|
||||
try: count = int(request.form.get('series_count', 1))
|
||||
except: count = 3
|
||||
|
||||
concept = request.form.get('concept', '')
|
||||
|
||||
for i in range(count):
|
||||
bible['books'].append({
|
||||
"book_number": i+1,
|
||||
"title": f"{title} - Book {i+1}" if is_series else title,
|
||||
"manual_instruction": concept if i==0 else "",
|
||||
"plot_beats": []
|
||||
})
|
||||
|
||||
try:
|
||||
ai_setup.init_models()
|
||||
bible = planner.enrich(bible, proj_path)
|
||||
except: pass
|
||||
|
||||
with open(os.path.join(proj_path, "bible.json"), 'w') as f:
|
||||
json.dump(bible, f, indent=2)
|
||||
|
||||
new_proj = Project(user_id=current_user.id, name=title, folder_path=proj_path)
|
||||
db.session.add(new_proj)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('project.view_project', id=new_proj.id))
|
||||
|
||||
|
||||
@project_bp.route('/project/import', methods=['POST'])
|
||||
@login_required
|
||||
def import_project():
|
||||
if 'bible_file' not in request.files:
|
||||
flash('No file part')
|
||||
return redirect(url_for('project.index'))
|
||||
|
||||
file = request.files['bible_file']
|
||||
if file.filename == '':
|
||||
flash('No selected file')
|
||||
return redirect(url_for('project.index'))
|
||||
|
||||
if file:
|
||||
try:
|
||||
bible = json.load(file)
|
||||
if 'project_metadata' not in bible or 'title' not in bible['project_metadata']:
|
||||
flash("Invalid Bible format: Missing project_metadata or title.")
|
||||
return redirect(url_for('project.index'))
|
||||
|
||||
title = bible['project_metadata']['title']
|
||||
safe_title = utils.sanitize_filename(title)
|
||||
|
||||
user_dir = os.path.join(config.DATA_DIR, "users", str(current_user.id))
|
||||
os.makedirs(user_dir, exist_ok=True)
|
||||
|
||||
proj_path = os.path.join(user_dir, safe_title)
|
||||
if os.path.exists(proj_path):
|
||||
safe_title += f"_{int(datetime.utcnow().timestamp())}"
|
||||
proj_path = os.path.join(user_dir, safe_title)
|
||||
os.makedirs(proj_path)
|
||||
|
||||
with open(os.path.join(proj_path, "bible.json"), 'w') as f:
|
||||
json.dump(bible, f, indent=2)
|
||||
|
||||
new_proj = Project(user_id=current_user.id, name=title, folder_path=proj_path)
|
||||
db.session.add(new_proj)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"Project '{title}' imported successfully.")
|
||||
return redirect(url_for('project.view_project', id=new_proj.id))
|
||||
|
||||
except Exception as e:
|
||||
flash(f"Import failed: {str(e)}")
|
||||
return redirect(url_for('project.index'))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>')
|
||||
@login_required
|
||||
def view_project(id):
|
||||
proj = db.session.get(Project, id)
|
||||
if not proj: return "Project not found", 404
|
||||
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
bible_data = utils.load_json(bible_path)
|
||||
|
||||
draft_path = os.path.join(proj.folder_path, "bible_draft.json")
|
||||
has_draft = os.path.exists(draft_path)
|
||||
is_refining = os.path.exists(os.path.join(proj.folder_path, ".refining"))
|
||||
|
||||
personas = {}
|
||||
if os.path.exists(config.PERSONAS_FILE):
|
||||
try:
|
||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
||||
except: pass
|
||||
|
||||
runs = Run.query.filter_by(project_id=id).order_by(Run.id.desc()).all()
|
||||
latest_run = runs[0] if runs else None
|
||||
|
||||
other_projects = Project.query.filter(Project.user_id == current_user.id, Project.id != id).all()
|
||||
|
||||
artifacts = []
|
||||
cover_image = None
|
||||
generated_books = {}
|
||||
locked = is_project_locked(id)
|
||||
|
||||
for r in runs:
|
||||
if r.status == 'completed':
|
||||
run_dir = os.path.join(proj.folder_path, "runs", f"run_{r.id}")
|
||||
if os.path.exists(run_dir):
|
||||
for d in os.listdir(run_dir):
|
||||
if d.startswith("Book_") and os.path.isdir(os.path.join(run_dir, d)):
|
||||
if os.path.exists(os.path.join(run_dir, d, "manuscript.json")):
|
||||
try:
|
||||
parts = d.split('_')
|
||||
if len(parts) > 1 and parts[1].isdigit():
|
||||
b_num = int(parts[1])
|
||||
if b_num not in generated_books:
|
||||
book_path = os.path.join(run_dir, d)
|
||||
epub_file = next((f for f in os.listdir(book_path) if f.endswith('.epub')), None)
|
||||
docx_file = next((f for f in os.listdir(book_path) if f.endswith('.docx')), None)
|
||||
generated_books[b_num] = {'status': 'generated', 'run_id': r.id, 'folder': d, 'epub': os.path.join(d, epub_file).replace("\\", "/") if epub_file else None, 'docx': os.path.join(d, docx_file).replace("\\", "/") if docx_file else None}
|
||||
except: pass
|
||||
|
||||
if latest_run:
|
||||
run_dir = os.path.join(proj.folder_path, "runs", f"run_{latest_run.id}")
|
||||
if os.path.exists(run_dir):
|
||||
if os.path.exists(os.path.join(run_dir, "cover.png")):
|
||||
cover_image = "cover.png"
|
||||
else:
|
||||
subdirs = utils.get_sorted_book_folders(run_dir)
|
||||
for d in subdirs:
|
||||
if os.path.exists(os.path.join(run_dir, d, "cover.png")):
|
||||
cover_image = os.path.join(d, "cover.png").replace("\\", "/")
|
||||
break
|
||||
|
||||
for root, dirs, files in os.walk(run_dir):
|
||||
for f in files:
|
||||
if f.lower().endswith(('.epub', '.docx')):
|
||||
rel_path = os.path.relpath(os.path.join(root, f), run_dir)
|
||||
artifacts.append({
|
||||
'name': f,
|
||||
'path': rel_path.replace("\\", "/"),
|
||||
'type': f.split('.')[-1].upper()
|
||||
})
|
||||
|
||||
return render_template('project.html', project=proj, bible=bible_data, runs=runs, active_run=latest_run, artifacts=artifacts, cover_image=cover_image, personas=personas, generated_books=generated_books, other_projects=other_projects, locked=locked, has_draft=has_draft, is_refining=is_refining)
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/run', methods=['POST'])
|
||||
@login_required
|
||||
def run_project(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
|
||||
new_run = Run(project_id=id, status="queued")
|
||||
db.session.add(new_run)
|
||||
db.session.commit()
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
generate_book_task(new_run.id, proj.folder_path, bible_path, allow_copy=True)
|
||||
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/review')
|
||||
@login_required
|
||||
def review_project(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
bible = utils.load_json(bible_path)
|
||||
|
||||
return render_template('project_review.html', project=proj, bible=bible)
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/update', methods=['POST'])
|
||||
@login_required
|
||||
def update_project_metadata(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
new_title = request.form.get('title')
|
||||
new_author = request.form.get('author')
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
bible = utils.load_json(bible_path)
|
||||
|
||||
if bible:
|
||||
if new_title:
|
||||
bible['project_metadata']['title'] = new_title
|
||||
proj.name = new_title
|
||||
if new_author:
|
||||
bible['project_metadata']['author'] = new_author
|
||||
|
||||
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/clone', methods=['POST'])
|
||||
@login_required
|
||||
def clone_project(id):
|
||||
source_proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if source_proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
new_name = request.form.get('new_name')
|
||||
instruction = request.form.get('instruction')
|
||||
|
||||
safe_title = utils.sanitize_filename(new_name)
|
||||
user_dir = os.path.join(config.DATA_DIR, "users", str(current_user.id))
|
||||
new_path = os.path.join(user_dir, safe_title)
|
||||
if os.path.exists(new_path):
|
||||
safe_title += f"_{int(datetime.utcnow().timestamp())}"
|
||||
new_path = os.path.join(user_dir, safe_title)
|
||||
os.makedirs(new_path)
|
||||
|
||||
source_bible_path = os.path.join(source_proj.folder_path, "bible.json")
|
||||
if os.path.exists(source_bible_path):
|
||||
bible = utils.load_json(source_bible_path)
|
||||
bible['project_metadata']['title'] = new_name
|
||||
|
||||
if instruction:
|
||||
try:
|
||||
ai_setup.init_models()
|
||||
bible = bible_tracker.refine_bible(bible, instruction, new_path) or bible
|
||||
except: pass
|
||||
|
||||
with open(os.path.join(new_path, "bible.json"), 'w') as f: json.dump(bible, f, indent=2)
|
||||
|
||||
new_proj = Project(user_id=current_user.id, name=new_name, folder_path=new_path)
|
||||
db.session.add(new_proj)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"Project cloned as '{new_name}'.")
|
||||
return redirect(url_for('project.view_project', id=new_proj.id))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/bible_comparison')
|
||||
@login_required
|
||||
def bible_comparison(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
draft_path = os.path.join(proj.folder_path, "bible_draft.json")
|
||||
|
||||
if not os.path.exists(draft_path):
|
||||
flash("No draft found. Please refine the bible first.")
|
||||
return redirect(url_for('project.review_project', id=id))
|
||||
|
||||
original = utils.load_json(bible_path)
|
||||
new_draft = utils.load_json(draft_path)
|
||||
|
||||
if not original or not new_draft:
|
||||
flash("Error loading bible data. Draft may be corrupt.")
|
||||
return redirect(url_for('project.review_project', id=id))
|
||||
|
||||
return render_template('bible_comparison.html', project=proj, original=original, new=new_draft)
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/refine_bible', methods=['POST'])
|
||||
@login_required
|
||||
def refine_bible_route(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
data = request.json if request.is_json else request.form
|
||||
instruction = data.get('instruction')
|
||||
|
||||
if not instruction:
|
||||
return {"error": "Instruction required"}, 400
|
||||
|
||||
source_type = data.get('source', 'original')
|
||||
selected_keys = data.get('selected_keys')
|
||||
if isinstance(selected_keys, str):
|
||||
try: selected_keys = json.loads(selected_keys) if selected_keys.strip() else []
|
||||
except: selected_keys = []
|
||||
|
||||
task = refine_bible_task(proj.folder_path, instruction, source_type, selected_keys)
|
||||
|
||||
return {"status": "queued", "task_id": task.id}
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/is_refining')
|
||||
@login_required
|
||||
def check_refinement_status(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
is_refining = os.path.exists(os.path.join(proj.folder_path, ".refining"))
|
||||
return {"is_refining": is_refining}
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/refine_bible/confirm', methods=['POST'])
|
||||
@login_required
|
||||
def confirm_bible_refinement(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
action = request.form.get('action')
|
||||
draft_path = os.path.join(proj.folder_path, "bible_draft.json")
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
|
||||
if action == 'accept' or action == 'accept_all':
|
||||
if os.path.exists(draft_path):
|
||||
shutil.move(draft_path, bible_path)
|
||||
flash("Bible updated successfully.")
|
||||
else:
|
||||
flash("Draft expired or missing.")
|
||||
|
||||
elif action == 'accept_selected':
|
||||
if os.path.exists(draft_path) and os.path.exists(bible_path):
|
||||
selected_keys_json = request.form.get('selected_keys', '[]')
|
||||
try:
|
||||
selected_keys = json.loads(selected_keys_json)
|
||||
draft = utils.load_json(draft_path)
|
||||
original = utils.load_json(bible_path)
|
||||
|
||||
original = bible_tracker.merge_selected_changes(original, draft, selected_keys)
|
||||
|
||||
with open(bible_path, 'w') as f: json.dump(original, f, indent=2)
|
||||
os.remove(draft_path)
|
||||
flash(f"Merged {len(selected_keys)} changes into Bible.")
|
||||
|
||||
except Exception as e:
|
||||
flash(f"Merge failed: {e}")
|
||||
else:
|
||||
flash("Files missing.")
|
||||
|
||||
elif action == 'decline':
|
||||
if os.path.exists(draft_path):
|
||||
os.remove(draft_path)
|
||||
flash("Changes discarded.")
|
||||
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/add_book', methods=['POST'])
|
||||
@login_required
|
||||
def add_book(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
title = request.form.get('title', 'Untitled')
|
||||
instruction = request.form.get('instruction', '')
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
bible = utils.load_json(bible_path)
|
||||
|
||||
if bible:
|
||||
if 'books' not in bible: bible['books'] = []
|
||||
next_num = len(bible['books']) + 1
|
||||
|
||||
new_book = {
|
||||
"book_number": next_num,
|
||||
"title": title,
|
||||
"manual_instruction": instruction,
|
||||
"plot_beats": []
|
||||
}
|
||||
bible['books'].append(new_book)
|
||||
|
||||
if 'project_metadata' in bible:
|
||||
bible['project_metadata']['is_series'] = True
|
||||
|
||||
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
|
||||
flash(f"Added Book {next_num}: {title}")
|
||||
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/book/<int:book_num>/update', methods=['POST'])
|
||||
@login_required
|
||||
def update_book_details(id, book_num):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
new_title = request.form.get('title')
|
||||
new_instruction = request.form.get('instruction')
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
bible = utils.load_json(bible_path)
|
||||
|
||||
if bible and 'books' in bible:
|
||||
for b in bible['books']:
|
||||
if b.get('book_number') == book_num:
|
||||
if new_title: b['title'] = new_title
|
||||
if new_instruction is not None: b['manual_instruction'] = new_instruction
|
||||
break
|
||||
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
|
||||
flash(f"Book {book_num} updated.")
|
||||
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/delete_book/<int:book_num>', methods=['POST'])
|
||||
@login_required
|
||||
def delete_book(id, book_num):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
bible = utils.load_json(bible_path)
|
||||
|
||||
if bible and 'books' in bible:
|
||||
bible['books'] = [b for b in bible['books'] if b.get('book_number') != book_num]
|
||||
for i, b in enumerate(bible['books']):
|
||||
b['book_number'] = i + 1
|
||||
|
||||
if 'project_metadata' in bible:
|
||||
bible['project_metadata']['is_series'] = (len(bible['books']) > 1)
|
||||
|
||||
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
|
||||
flash("Book deleted from plan.")
|
||||
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/import_characters', methods=['POST'])
|
||||
@login_required
|
||||
def import_characters(id):
|
||||
target_proj = db.session.get(Project, id)
|
||||
source_id = request.form.get('source_project_id')
|
||||
source_proj = db.session.get(Project, source_id)
|
||||
|
||||
if not target_proj or not source_proj: return "Project not found", 404
|
||||
if target_proj.user_id != current_user.id or source_proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
target_bible = utils.load_json(os.path.join(target_proj.folder_path, "bible.json"))
|
||||
source_bible = utils.load_json(os.path.join(source_proj.folder_path, "bible.json"))
|
||||
|
||||
if target_bible and source_bible:
|
||||
existing_names = {c['name'].lower() for c in target_bible.get('characters', [])}
|
||||
added_count = 0
|
||||
|
||||
for char in source_bible.get('characters', []):
|
||||
if char['name'].lower() not in existing_names:
|
||||
target_bible['characters'].append(char)
|
||||
added_count += 1
|
||||
|
||||
if added_count > 0:
|
||||
with open(os.path.join(target_proj.folder_path, "bible.json"), 'w') as f:
|
||||
json.dump(target_bible, f, indent=2)
|
||||
flash(f"Imported {added_count} characters from {source_proj.name}.")
|
||||
else:
|
||||
flash("No new characters found to import.")
|
||||
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:id>/set_persona', methods=['POST'])
|
||||
@login_required
|
||||
def set_project_persona(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
persona_name = request.form.get('persona_name')
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
bible = utils.load_json(bible_path)
|
||||
|
||||
if bible:
|
||||
personas = {}
|
||||
if os.path.exists(config.PERSONAS_FILE):
|
||||
try:
|
||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
||||
except: pass
|
||||
|
||||
if persona_name in personas:
|
||||
bible['project_metadata']['author_details'] = personas[persona_name]
|
||||
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
|
||||
flash(f"Project voice updated to persona: {persona_name}")
|
||||
else:
|
||||
flash("Persona not found.")
|
||||
|
||||
return redirect(url_for('project.view_project', id=id))
|
||||
|
||||
|
||||
@project_bp.route('/run/<int:id>/stop', methods=['POST'])
|
||||
@login_required
|
||||
def stop_run(id):
|
||||
run = db.session.get(Run, id) or Run.query.get_or_404(id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if run.status in ['queued', 'running']:
|
||||
run.status = 'cancelled'
|
||||
run.end_time = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
if os.path.exists(run_dir):
|
||||
with open(os.path.join(run_dir, ".stop"), 'w') as f: f.write("stop")
|
||||
|
||||
flash(f"Run {id} marked as cancelled.")
|
||||
|
||||
return redirect(url_for('project.view_project', id=run.project_id))
|
||||
|
||||
|
||||
@project_bp.route('/run/<int:id>/restart', methods=['POST'])
|
||||
@login_required
|
||||
def restart_run(id):
|
||||
run = db.session.get(Run, id) or Run.query.get_or_404(id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
new_run = Run(project_id=run.project_id, status="queued")
|
||||
db.session.add(new_run)
|
||||
db.session.commit()
|
||||
|
||||
mode = request.form.get('mode', 'resume')
|
||||
feedback = request.form.get('feedback')
|
||||
keep_cover = 'keep_cover' in request.form
|
||||
force_regen = 'force_regenerate' in request.form
|
||||
allow_copy = (mode == 'resume' and not force_regen)
|
||||
if feedback: allow_copy = False
|
||||
|
||||
generate_book_task(new_run.id, run.project.folder_path, os.path.join(run.project.folder_path, "bible.json"), allow_copy=allow_copy, feedback=feedback, source_run_id=id if feedback else None, keep_cover=keep_cover)
|
||||
flash(f"Started new Run #{new_run.id}" + (" with modifications." if feedback else "."))
|
||||
return redirect(url_for('project.view_project', id=run.project_id))
|
||||
|
||||
|
||||
@project_bp.route('/project/<int:run_id>/revise_book/<string:book_folder>', methods=['POST'])
|
||||
@login_required
|
||||
def revise_book(run_id, book_folder):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
instruction = request.form.get('instruction')
|
||||
|
||||
new_run = Run(project_id=run.project_id, status="queued")
|
||||
db.session.add(new_run)
|
||||
db.session.commit()
|
||||
|
||||
generate_book_task(
|
||||
new_run.id,
|
||||
run.project.folder_path,
|
||||
os.path.join(run.project.folder_path, "bible.json"),
|
||||
allow_copy=True,
|
||||
feedback=instruction,
|
||||
source_run_id=run.id,
|
||||
keep_cover=True,
|
||||
exclude_folders=[book_folder]
|
||||
)
|
||||
|
||||
flash(f"Started Revision Run #{new_run.id}. Book '{book_folder}' will be regenerated.")
|
||||
return redirect(url_for('project.view_project', id=run.project_id))
|
||||
335
web/routes/run.py
Normal file
335
web/routes/run.py
Normal file
@@ -0,0 +1,335 @@
|
||||
import os
|
||||
import json
|
||||
import markdown
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, send_from_directory
|
||||
from flask_login import login_required, current_user
|
||||
from web.db import db, Run, LogEntry
|
||||
from core import utils
|
||||
from ai import models as ai_models
|
||||
from ai import setup as ai_setup
|
||||
from story import editor as story_editor
|
||||
from story import bible_tracker, style_persona
|
||||
from export import exporter
|
||||
from web.tasks import huey, regenerate_artifacts_task, rewrite_chapter_task
|
||||
|
||||
run_bp = Blueprint('run', __name__)
|
||||
|
||||
|
||||
@run_bp.route('/run/<int:id>')
|
||||
@login_required
|
||||
def view_run(id):
|
||||
run = db.session.get(Run, id)
|
||||
if not run: return "Run not found", 404
|
||||
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
log_content = ""
|
||||
logs = LogEntry.query.filter_by(run_id=id).order_by(LogEntry.timestamp).all()
|
||||
if logs:
|
||||
log_content = "\n".join([f"[{l.timestamp.strftime('%H:%M:%S')}] {l.phase:<15} | {l.message}" for l in logs])
|
||||
elif run.log_file and os.path.exists(run.log_file):
|
||||
with open(run.log_file, 'r') as f: log_content = f.read()
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
|
||||
books_data = []
|
||||
if os.path.exists(run_dir):
|
||||
subdirs = utils.get_sorted_book_folders(run_dir)
|
||||
|
||||
for d in subdirs:
|
||||
b_path = os.path.join(run_dir, d)
|
||||
b_info = {'folder': d, 'artifacts': [], 'cover': None, 'blurb': ''}
|
||||
|
||||
for f in os.listdir(b_path):
|
||||
if f.lower().endswith(('.epub', '.docx')):
|
||||
b_info['artifacts'].append({'name': f, 'path': os.path.join(d, f).replace("\\", "/")})
|
||||
|
||||
if os.path.exists(os.path.join(b_path, "cover.png")):
|
||||
b_info['cover'] = os.path.join(d, "cover.png").replace("\\", "/")
|
||||
|
||||
blurb_p = os.path.join(b_path, "blurb.txt")
|
||||
if os.path.exists(blurb_p):
|
||||
with open(blurb_p, 'r', encoding='utf-8', errors='ignore') as f: b_info['blurb'] = f.read()
|
||||
|
||||
books_data.append(b_info)
|
||||
|
||||
bible_path = os.path.join(run.project.folder_path, "bible.json")
|
||||
bible_data = utils.load_json(bible_path)
|
||||
|
||||
tracking = {"events": [], "characters": {}, "content_warnings": []}
|
||||
book_dir = os.path.join(run_dir, books_data[-1]['folder']) if books_data else run_dir
|
||||
if os.path.exists(book_dir):
|
||||
t_ev = os.path.join(book_dir, "tracking_events.json")
|
||||
t_ch = os.path.join(book_dir, "tracking_characters.json")
|
||||
t_wn = os.path.join(book_dir, "tracking_warnings.json")
|
||||
|
||||
if os.path.exists(t_ev): tracking['events'] = utils.load_json(t_ev) or []
|
||||
if os.path.exists(t_ch): tracking['characters'] = utils.load_json(t_ch) or {}
|
||||
if os.path.exists(t_wn): tracking['content_warnings'] = utils.load_json(t_wn) or []
|
||||
|
||||
return render_template('run_details.html', run=run, log_content=log_content, books=books_data, bible=bible_data, tracking=tracking)
|
||||
|
||||
|
||||
@run_bp.route('/run/<int:id>/status')
|
||||
@login_required
|
||||
def run_status(id):
|
||||
run = db.session.get(Run, id) or Run.query.get_or_404(id)
|
||||
|
||||
log_content = ""
|
||||
last_log = None
|
||||
|
||||
logs = LogEntry.query.filter_by(run_id=id).order_by(LogEntry.timestamp).all()
|
||||
if logs:
|
||||
log_content = "\n".join([f"[{l.timestamp.strftime('%H:%M:%S')}] {l.phase:<15} | {l.message}" for l in logs])
|
||||
last_log = logs[-1]
|
||||
|
||||
if not log_content:
|
||||
if run.log_file and os.path.exists(run.log_file):
|
||||
with open(run.log_file, 'r') as f: log_content = f.read()
|
||||
elif run.status in ['queued', 'running']:
|
||||
temp_log = os.path.join(run.project.folder_path, f"system_log_{run.id}.txt")
|
||||
if os.path.exists(temp_log):
|
||||
with open(temp_log, 'r') as f: log_content = f.read()
|
||||
|
||||
response = {
|
||||
"status": run.status,
|
||||
"log": log_content,
|
||||
"cost": run.cost,
|
||||
"percent": run.progress,
|
||||
"start_time": run.start_time.timestamp() if run.start_time else None
|
||||
}
|
||||
|
||||
if last_log:
|
||||
response["progress"] = {
|
||||
"phase": last_log.phase,
|
||||
"message": last_log.message,
|
||||
"timestamp": last_log.timestamp.timestamp()
|
||||
}
|
||||
return response
|
||||
|
||||
|
||||
@run_bp.route('/project/<int:run_id>/download')
|
||||
@login_required
|
||||
def download_artifact(run_id):
|
||||
filename = request.args.get('file')
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if not filename: return "Missing filename", 400
|
||||
|
||||
if os.path.isabs(filename) or ".." in os.path.normpath(filename) or ":" in filename:
|
||||
return "Invalid filename", 400
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
|
||||
if not os.path.exists(os.path.join(run_dir, filename)) and os.path.exists(run_dir):
|
||||
subdirs = utils.get_sorted_book_folders(run_dir)
|
||||
for d in subdirs:
|
||||
possible_path = os.path.join(d, filename)
|
||||
if os.path.exists(os.path.join(run_dir, possible_path)):
|
||||
filename = possible_path
|
||||
break
|
||||
|
||||
return send_from_directory(run_dir, filename, as_attachment=True)
|
||||
|
||||
|
||||
@run_bp.route('/project/<int:run_id>/read/<string:book_folder>')
|
||||
@login_required
|
||||
def read_book(run_id, book_folder):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
book_path = os.path.join(run_dir, book_folder)
|
||||
ms_path = os.path.join(book_path, "manuscript.json")
|
||||
|
||||
if not os.path.exists(ms_path):
|
||||
flash("Manuscript not found.")
|
||||
return redirect(url_for('run.view_run', id=run_id))
|
||||
|
||||
manuscript = utils.load_json(ms_path)
|
||||
manuscript.sort(key=utils.chapter_sort_key)
|
||||
|
||||
for ch in manuscript:
|
||||
ch['html_content'] = markdown.markdown(ch.get('content', ''))
|
||||
|
||||
return render_template('read_book.html', run=run, book_folder=book_folder, manuscript=manuscript)
|
||||
|
||||
|
||||
@run_bp.route('/project/<int:run_id>/save_chapter', methods=['POST'])
|
||||
@login_required
|
||||
def save_chapter(run_id):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if run.status == 'running':
|
||||
return "Cannot edit chapter while run is active.", 409
|
||||
|
||||
book_folder = request.form.get('book_folder')
|
||||
chap_num_raw = request.form.get('chapter_num')
|
||||
try: chap_num = int(chap_num_raw)
|
||||
except: chap_num = chap_num_raw
|
||||
new_content = request.form.get('content')
|
||||
|
||||
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
ms_path = os.path.join(run_dir, book_folder, "manuscript.json")
|
||||
|
||||
if os.path.exists(ms_path):
|
||||
ms = utils.load_json(ms_path)
|
||||
for ch in ms:
|
||||
if str(ch.get('num')) == str(chap_num):
|
||||
ch['content'] = new_content
|
||||
break
|
||||
with open(ms_path, 'w') as f: json.dump(ms, f, indent=2)
|
||||
|
||||
book_path = os.path.join(run_dir, book_folder)
|
||||
bp_path = os.path.join(book_path, "final_blueprint.json")
|
||||
if os.path.exists(bp_path):
|
||||
bp = utils.load_json(bp_path)
|
||||
exporter.compile_files(bp, ms, book_path)
|
||||
|
||||
return "Saved", 200
|
||||
return "Error", 500
|
||||
|
||||
|
||||
@run_bp.route('/project/<int:run_id>/check_consistency/<string:book_folder>')
|
||||
@login_required
|
||||
def check_consistency(run_id, book_folder):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
|
||||
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
book_path = os.path.join(run_dir, book_folder)
|
||||
|
||||
bp = utils.load_json(os.path.join(book_path, "final_blueprint.json"))
|
||||
ms = utils.load_json(os.path.join(book_path, "manuscript.json"))
|
||||
|
||||
if not bp or not ms:
|
||||
return "Data files missing or corrupt.", 404
|
||||
|
||||
try: ai_setup.init_models()
|
||||
except: pass
|
||||
|
||||
report = story_editor.analyze_consistency(bp, ms, book_path)
|
||||
return render_template('consistency_report.html', report=report, run=run, book_folder=book_folder)
|
||||
|
||||
|
||||
@run_bp.route('/project/<int:run_id>/sync_book/<string:book_folder>', methods=['POST'])
|
||||
@login_required
|
||||
def sync_book_metadata(run_id, book_folder):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if run.status == 'running':
|
||||
flash("Cannot sync metadata while run is active.")
|
||||
return redirect(url_for('run.read_book', run_id=run_id, book_folder=book_folder))
|
||||
|
||||
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
book_path = os.path.join(run_dir, book_folder)
|
||||
|
||||
ms_path = os.path.join(book_path, "manuscript.json")
|
||||
bp_path = os.path.join(book_path, "final_blueprint.json")
|
||||
|
||||
if os.path.exists(ms_path) and os.path.exists(bp_path):
|
||||
ms = utils.load_json(ms_path)
|
||||
bp = utils.load_json(bp_path)
|
||||
|
||||
if not ms or not bp:
|
||||
flash("Data files corrupt.")
|
||||
return redirect(url_for('run.read_book', run_id=run_id, book_folder=book_folder))
|
||||
|
||||
try: ai_setup.init_models()
|
||||
except: pass
|
||||
|
||||
bp = bible_tracker.harvest_metadata(bp, book_path, ms)
|
||||
|
||||
tracking_path = os.path.join(book_path, "tracking_characters.json")
|
||||
if os.path.exists(tracking_path):
|
||||
tracking_chars = utils.load_json(tracking_path) or {}
|
||||
updated_tracking = False
|
||||
for c in bp.get('characters', []):
|
||||
if c.get('name') and c['name'] not in tracking_chars:
|
||||
tracking_chars[c['name']] = {"descriptors": [c.get('description', '')], "likes_dislikes": [], "last_worn": "Unknown"}
|
||||
updated_tracking = True
|
||||
if updated_tracking:
|
||||
with open(tracking_path, 'w') as f: json.dump(tracking_chars, f, indent=2)
|
||||
|
||||
style_persona.update_persona_sample(bp, book_path)
|
||||
|
||||
with open(bp_path, 'w') as f: json.dump(bp, f, indent=2)
|
||||
flash("Metadata synced. Future generations will respect your edits.")
|
||||
else:
|
||||
flash("Files not found.")
|
||||
|
||||
return redirect(url_for('run.read_book', run_id=run_id, book_folder=book_folder))
|
||||
|
||||
|
||||
@run_bp.route('/project/<int:run_id>/rewrite_chapter', methods=['POST'])
|
||||
@login_required
|
||||
def rewrite_chapter(run_id):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id:
|
||||
return {"error": "Unauthorized"}, 403
|
||||
|
||||
if run.status == 'running':
|
||||
return {"error": "Cannot rewrite while run is active."}, 409
|
||||
|
||||
data = request.json
|
||||
book_folder = data.get('book_folder')
|
||||
chap_num = data.get('chapter_num')
|
||||
instruction = data.get('instruction')
|
||||
|
||||
if not book_folder or chap_num is None or not instruction:
|
||||
return {"error": "Missing parameters"}, 400
|
||||
|
||||
if "/" in book_folder or "\\" in book_folder or ".." in book_folder: return {"error": "Invalid book folder"}, 400
|
||||
|
||||
try: chap_num = int(chap_num)
|
||||
except: pass
|
||||
|
||||
task = rewrite_chapter_task(run.id, run.project.folder_path, book_folder, chap_num, instruction)
|
||||
|
||||
session['rewrite_task_id'] = task.id
|
||||
|
||||
return {"status": "queued", "task_id": task.id}, 202
|
||||
|
||||
|
||||
@run_bp.route('/task_status/<string:task_id>')
|
||||
@login_required
|
||||
def get_task_status(task_id):
|
||||
try:
|
||||
task_result = huey.result(task_id, preserve=True)
|
||||
except Exception as e:
|
||||
return {"status": "completed", "success": False, "error": str(e)}
|
||||
|
||||
if task_result is None:
|
||||
return {"status": "running"}
|
||||
else:
|
||||
return {"status": "completed", "success": task_result}
|
||||
|
||||
|
||||
@run_bp.route('/project/<int:run_id>/regenerate_artifacts', methods=['POST'])
|
||||
@login_required
|
||||
def regenerate_artifacts(run_id):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if run.status == 'running':
|
||||
flash("Run is already active. Please wait for it to finish.")
|
||||
return redirect(url_for('run.view_run', id=run_id))
|
||||
|
||||
feedback = request.form.get('feedback')
|
||||
|
||||
run.status = 'queued'
|
||||
db.session.commit()
|
||||
|
||||
regenerate_artifacts_task(run_id, run.project.folder_path, feedback=feedback)
|
||||
flash("Regenerating cover and files with updated metadata...")
|
||||
return redirect(url_for('run.view_run', id=run_id))
|
||||
@@ -5,10 +5,13 @@ import sqlite3
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from huey import SqliteHuey
|
||||
from .web_db import db, Run, User, Project
|
||||
from . import utils
|
||||
import config
|
||||
from . import story, ai, marketing, export
|
||||
from web.db import db, Run, User, Project
|
||||
from core import utils, config
|
||||
from ai import models as ai_models
|
||||
from ai import setup as ai_setup
|
||||
from story import bible_tracker
|
||||
from marketing import cover as marketing_cover
|
||||
from export import exporter
|
||||
|
||||
# Configure Huey (Task Queue)
|
||||
huey = SqliteHuey('bookapp_queue', filename=os.path.join(config.DATA_DIR, 'queue.db'))
|
||||
@@ -18,7 +21,7 @@ def db_log_callback(db_path, run_id, phase, msg):
|
||||
for _ in range(5):
|
||||
try:
|
||||
with sqlite3.connect(db_path, timeout=5) as conn:
|
||||
conn.execute("INSERT INTO log_entry (run_id, timestamp, phase, message) VALUES (?, ?, ?, ?)",
|
||||
conn.execute("INSERT INTO log_entry (run_id, timestamp, phase, message) VALUES (?, ?, ?, ?)",
|
||||
(run_id, datetime.utcnow(), phase, str(msg)))
|
||||
break
|
||||
except sqlite3.OperationalError:
|
||||
@@ -42,85 +45,74 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
|
||||
"""
|
||||
# 1. Setup Logging
|
||||
log_filename = f"system_log_{run_id}.txt"
|
||||
|
||||
# Log to project root initially until run folder is created by main
|
||||
|
||||
# Log to project root initially until run folder is created by engine
|
||||
initial_log = os.path.join(project_path, log_filename)
|
||||
utils.set_log_file(initial_log)
|
||||
|
||||
|
||||
# Hook up Database Logging
|
||||
db_path = os.path.join(config.DATA_DIR, "bookapp.db")
|
||||
utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m))
|
||||
utils.set_progress_callback(lambda p: db_progress_callback(db_path, run_id, p))
|
||||
|
||||
|
||||
# Set Status to Running
|
||||
try:
|
||||
with sqlite3.connect(db_path, timeout=10) as conn:
|
||||
conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,))
|
||||
except: pass
|
||||
|
||||
|
||||
utils.log("SYSTEM", f"Starting Job #{run_id}")
|
||||
|
||||
|
||||
try:
|
||||
# 1.1 Handle Feedback / Modification (Re-run logic)
|
||||
if feedback and source_run_id:
|
||||
utils.log("SYSTEM", f"Applying feedback to Run #{source_run_id}: '{feedback}'")
|
||||
|
||||
# Load Source Data (Prefer final_blueprint from source run to capture its state)
|
||||
source_run_dir = os.path.join(project_path, "runs", f"run_{source_run_id}")
|
||||
|
||||
bible_data = utils.load_json(bible_path)
|
||||
|
||||
# Try to find the blueprint of the book in the source run
|
||||
# (Simplification: If multiple books, we apply feedback to the Bible generally)
|
||||
|
||||
if bible_data:
|
||||
try:
|
||||
ai.init_models()
|
||||
new_bible = story.refine_bible(bible_data, feedback, project_path)
|
||||
ai_setup.init_models()
|
||||
new_bible = bible_tracker.refine_bible(bible_data, feedback, project_path)
|
||||
if new_bible:
|
||||
bible_data = new_bible
|
||||
# Save updated Bible (This updates the project state to the new "fork")
|
||||
with open(bible_path, 'w') as f: json.dump(bible_data, f, indent=2)
|
||||
utils.log("SYSTEM", "Bible updated with feedback.")
|
||||
except Exception as e:
|
||||
utils.log("ERROR", f"Failed to refine bible: {e}")
|
||||
|
||||
# 1.2 Keep Cover Art Logic
|
||||
if keep_cover and os.path.exists(source_run_dir):
|
||||
utils.log("SYSTEM", "Attempting to preserve cover art...")
|
||||
|
||||
# We need to predict the new folder names to place the covers
|
||||
# main.py uses: Book_{n}_{safe_title}
|
||||
current_run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
|
||||
if not os.path.exists(current_run_dir): os.makedirs(current_run_dir)
|
||||
|
||||
# Map Source Books -> Target Books by Book Number
|
||||
source_books = {}
|
||||
for d in os.listdir(source_run_dir):
|
||||
if d.startswith("Book_") and os.path.isdir(os.path.join(source_run_dir, d)):
|
||||
parts = d.split('_')
|
||||
if len(parts) > 1 and parts[1].isdigit():
|
||||
source_books[int(parts[1])] = os.path.join(source_run_dir, d)
|
||||
|
||||
if bible_data and 'books' in bible_data:
|
||||
for i, book in enumerate(bible_data['books']):
|
||||
b_num = book.get('book_number', i+1)
|
||||
if b_num in source_books:
|
||||
# Found matching book in source
|
||||
src_folder = source_books[b_num]
|
||||
|
||||
# Predict Target Folder
|
||||
safe_title = utils.sanitize_filename(book.get('title', f"Book_{b_num}"))
|
||||
target_folder = os.path.join(current_run_dir, f"Book_{b_num}_{safe_title}")
|
||||
|
||||
os.makedirs(target_folder, exist_ok=True)
|
||||
|
||||
# Copy Cover
|
||||
src_cover = os.path.join(src_folder, "cover.png")
|
||||
if os.path.exists(src_cover):
|
||||
shutil.copy2(src_cover, os.path.join(target_folder, "cover.png"))
|
||||
# Also copy cover_art.png to prevent regeneration if logic allows
|
||||
if os.path.exists(os.path.join(src_folder, "cover_art.png")):
|
||||
shutil.copy2(os.path.join(src_folder, "cover_art.png"), os.path.join(target_folder, "cover_art.png"))
|
||||
utils.log("SYSTEM", f" -> Copied cover for Book {b_num}")
|
||||
if keep_cover:
|
||||
source_run_dir = os.path.join(project_path, "runs", f"run_{source_run_id}")
|
||||
if os.path.exists(source_run_dir):
|
||||
utils.log("SYSTEM", "Attempting to preserve cover art...")
|
||||
|
||||
current_run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
|
||||
if not os.path.exists(current_run_dir): os.makedirs(current_run_dir)
|
||||
|
||||
source_books = {}
|
||||
for d in os.listdir(source_run_dir):
|
||||
if d.startswith("Book_") and os.path.isdir(os.path.join(source_run_dir, d)):
|
||||
parts = d.split('_')
|
||||
if len(parts) > 1 and parts[1].isdigit():
|
||||
source_books[int(parts[1])] = os.path.join(source_run_dir, d)
|
||||
|
||||
if bible_data and 'books' in bible_data:
|
||||
for i, book in enumerate(bible_data['books']):
|
||||
b_num = book.get('book_number', i+1)
|
||||
if b_num in source_books:
|
||||
src_folder = source_books[b_num]
|
||||
safe_title = utils.sanitize_filename(book.get('title', f"Book_{b_num}"))
|
||||
target_folder = os.path.join(current_run_dir, f"Book_{b_num}_{safe_title}")
|
||||
|
||||
os.makedirs(target_folder, exist_ok=True)
|
||||
|
||||
src_cover = os.path.join(src_folder, "cover.png")
|
||||
if os.path.exists(src_cover):
|
||||
shutil.copy2(src_cover, os.path.join(target_folder, "cover.png"))
|
||||
if os.path.exists(os.path.join(src_folder, "cover_art.png")):
|
||||
shutil.copy2(os.path.join(src_folder, "cover_art.png"), os.path.join(target_folder, "cover_art.png"))
|
||||
utils.log("SYSTEM", f" -> Copied cover for Book {b_num}")
|
||||
|
||||
# 1.5 Copy Forward Logic (Series Optimization)
|
||||
is_series = False
|
||||
@@ -130,27 +122,23 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
|
||||
is_series = bible_data.get('project_metadata', {}).get('is_series', False)
|
||||
|
||||
runs_dir = os.path.join(project_path, "runs")
|
||||
|
||||
# Only copy if explicitly requested AND it's a series (Standalone books get fresh re-rolls)
|
||||
|
||||
if allow_copy and is_series and os.path.exists(runs_dir):
|
||||
# Get all run folders except current
|
||||
all_runs = [d for d in os.listdir(runs_dir) if d.startswith("run_") and d != f"run_{run_id}"]
|
||||
# Sort by ID (ascending)
|
||||
all_runs.sort(key=lambda x: int(x.split('_')[1]) if x.split('_')[1].isdigit() else 0)
|
||||
|
||||
|
||||
if all_runs:
|
||||
latest_run_dir = os.path.join(runs_dir, all_runs[-1])
|
||||
current_run_dir = os.path.join(runs_dir, f"run_{run_id}")
|
||||
os.makedirs(current_run_dir, exist_ok=True)
|
||||
|
||||
|
||||
utils.log("SYSTEM", f"Checking previous run ({all_runs[-1]}) for completed books...")
|
||||
for item in os.listdir(latest_run_dir):
|
||||
# Copy only folders that look like books and have a manuscript
|
||||
if item.startswith("Book_") and os.path.isdir(os.path.join(latest_run_dir, item)):
|
||||
if exclude_folders and item in exclude_folders:
|
||||
utils.log("SYSTEM", f" -> Skipping copy of {item} (Target for revision).")
|
||||
continue
|
||||
|
||||
|
||||
if os.path.exists(os.path.join(latest_run_dir, item, "manuscript.json")):
|
||||
src = os.path.join(latest_run_dir, item)
|
||||
dst = os.path.join(current_run_dir, item)
|
||||
@@ -161,38 +149,32 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
|
||||
utils.log("SYSTEM", f" -> Failed to copy {item}: {e}")
|
||||
|
||||
# 2. Run Generation
|
||||
# We call the existing entry point
|
||||
from main import run_generation
|
||||
from cli.engine import run_generation
|
||||
run_generation(bible_path, specific_run_id=run_id)
|
||||
|
||||
|
||||
utils.log("SYSTEM", "Job Complete.")
|
||||
utils.update_progress(100)
|
||||
status = "completed"
|
||||
|
||||
|
||||
except Exception as e:
|
||||
utils.log("ERROR", f"Job Failed: {e}")
|
||||
status = "failed"
|
||||
|
||||
# 3. Calculate Cost & Cleanup
|
||||
# Use the specific run folder we know main.py used
|
||||
run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
|
||||
|
||||
|
||||
total_cost = 0.0
|
||||
final_log_path = initial_log
|
||||
|
||||
|
||||
if os.path.exists(run_dir):
|
||||
# Move our log file there
|
||||
final_log_path = os.path.join(run_dir, "web_console.log")
|
||||
if os.path.exists(initial_log):
|
||||
try:
|
||||
os.rename(initial_log, final_log_path)
|
||||
except OSError:
|
||||
# If rename fails (e.g. across filesystems), copy and delete
|
||||
shutil.copy2(initial_log, final_log_path)
|
||||
os.remove(initial_log)
|
||||
|
||||
# Calculate Total Cost from all Book subfolders
|
||||
# usage_log.json is inside each Book folder
|
||||
|
||||
for item in os.listdir(run_dir):
|
||||
item_path = os.path.join(run_dir, item)
|
||||
if os.path.isdir(item_path) and item.startswith("Book_"):
|
||||
@@ -204,53 +186,46 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
|
||||
# 4. Update Database with Final Status
|
||||
try:
|
||||
with sqlite3.connect(db_path, timeout=10) as conn:
|
||||
conn.execute("UPDATE run SET status = ?, cost = ?, end_time = ?, log_file = ?, progress = 100 WHERE id = ?",
|
||||
conn.execute("UPDATE run SET status = ?, cost = ?, end_time = ?, log_file = ?, progress = 100 WHERE id = ?",
|
||||
(status, total_cost, datetime.utcnow(), final_log_path, run_id))
|
||||
except Exception as e:
|
||||
print(f"Failed to update run status in DB: {e}")
|
||||
|
||||
return {"run_id": run_id, "status": status, "cost": total_cost, "final_log": final_log_path}
|
||||
|
||||
|
||||
@huey.task()
|
||||
def regenerate_artifacts_task(run_id, project_path, feedback=None):
|
||||
# Hook up Database Logging & Status
|
||||
db_path = os.path.join(config.DATA_DIR, "bookapp.db")
|
||||
|
||||
# Determine log file path: Prefer the existing web_console.log in the run dir
|
||||
|
||||
run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
|
||||
log_file = os.path.join(run_dir, "web_console.log")
|
||||
|
||||
# Fallback to project root temp file if run dir doesn't exist (unlikely for regeneration)
|
||||
|
||||
if not os.path.exists(run_dir):
|
||||
log_file = os.path.join(project_path, f"system_log_{run_id}.txt")
|
||||
|
||||
# Clear previous logs (File) to refresh the log window
|
||||
try:
|
||||
with open(log_file, 'w', encoding='utf-8') as f:
|
||||
with open(log_file, 'w', encoding='utf-8') as f:
|
||||
f.write(f"[{datetime.utcnow().strftime('%H:%M:%S')}] --- REGENERATION STARTED ---\n")
|
||||
except: pass
|
||||
|
||||
utils.set_log_file(log_file)
|
||||
|
||||
utils.set_log_file(log_file)
|
||||
utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m))
|
||||
try:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("DELETE FROM log_entry WHERE run_id = ?", (run_id,)) # Clear DB logs
|
||||
conn.execute("DELETE FROM log_entry WHERE run_id = ?", (run_id,))
|
||||
conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,))
|
||||
except: pass
|
||||
|
||||
utils.log("SYSTEM", "Starting Artifact Regeneration...")
|
||||
|
||||
# 1. Setup Paths
|
||||
|
||||
# Detect Book Subfolder
|
||||
book_dir = run_dir
|
||||
if os.path.exists(run_dir):
|
||||
subdirs = utils.get_sorted_book_folders(run_dir)
|
||||
if subdirs: book_dir = os.path.join(run_dir, subdirs[0])
|
||||
|
||||
bible_path = os.path.join(project_path, "bible.json")
|
||||
|
||||
|
||||
if not os.path.exists(run_dir) or not os.path.exists(bible_path):
|
||||
utils.log("ERROR", "Run directory or Bible not found.")
|
||||
try:
|
||||
@@ -259,11 +234,10 @@ def regenerate_artifacts_task(run_id, project_path, feedback=None):
|
||||
except: pass
|
||||
return
|
||||
|
||||
# 2. Load Data
|
||||
bible = utils.load_json(bible_path)
|
||||
final_bp_path = os.path.join(book_dir, "final_blueprint.json")
|
||||
ms_path = os.path.join(book_dir, "manuscript.json")
|
||||
|
||||
|
||||
if not os.path.exists(final_bp_path) or not os.path.exists(ms_path):
|
||||
utils.log("ERROR", f"Blueprint or Manuscript not found in {book_dir}")
|
||||
try:
|
||||
@@ -271,21 +245,18 @@ def regenerate_artifacts_task(run_id, project_path, feedback=None):
|
||||
conn.execute("UPDATE run SET status = 'failed' WHERE id = ?", (run_id,))
|
||||
except: pass
|
||||
return
|
||||
|
||||
|
||||
bp = utils.load_json(final_bp_path)
|
||||
ms = utils.load_json(ms_path)
|
||||
|
||||
# 3. Update Blueprint with new Metadata from Bible
|
||||
|
||||
meta = bible.get('project_metadata', {})
|
||||
if 'book_metadata' in bp:
|
||||
# Sync all core metadata
|
||||
for k in ['author', 'genre', 'target_audience', 'style']:
|
||||
if k in meta:
|
||||
bp['book_metadata'][k] = meta[k]
|
||||
|
||||
|
||||
if bp.get('series_metadata', {}).get('is_series'):
|
||||
bp['series_metadata']['series_title'] = meta.get('title', bp['series_metadata'].get('series_title'))
|
||||
# Find specific book title from Bible
|
||||
b_num = bp['series_metadata'].get('book_number')
|
||||
for b in bible.get('books', []):
|
||||
if b.get('book_number') == b_num:
|
||||
@@ -293,21 +264,20 @@ def regenerate_artifacts_task(run_id, project_path, feedback=None):
|
||||
break
|
||||
else:
|
||||
bp['book_metadata']['title'] = meta.get('title', bp['book_metadata'].get('title'))
|
||||
|
||||
|
||||
with open(final_bp_path, 'w') as f: json.dump(bp, f, indent=2)
|
||||
|
||||
# 4. Regenerate
|
||||
|
||||
try:
|
||||
ai.init_models()
|
||||
|
||||
ai_setup.init_models()
|
||||
|
||||
tracking = None
|
||||
events_path = os.path.join(book_dir, "tracking_events.json")
|
||||
if os.path.exists(events_path):
|
||||
tracking = {"events": utils.load_json(events_path), "characters": utils.load_json(os.path.join(book_dir, "tracking_characters.json"))}
|
||||
|
||||
marketing.generate_cover(bp, book_dir, tracking, feedback=feedback)
|
||||
export.compile_files(bp, ms, book_dir)
|
||||
|
||||
|
||||
marketing_cover.generate_cover(bp, book_dir, tracking, feedback=feedback)
|
||||
exporter.compile_files(bp, ms, book_dir)
|
||||
|
||||
utils.log("SYSTEM", "Regeneration Complete.")
|
||||
final_status = 'completed'
|
||||
except Exception as e:
|
||||
@@ -319,6 +289,7 @@ def regenerate_artifacts_task(run_id, project_path, feedback=None):
|
||||
conn.execute("UPDATE run SET status = ? WHERE id = ?", (final_status, run_id))
|
||||
except: pass
|
||||
|
||||
|
||||
@huey.task()
|
||||
def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instruction):
|
||||
"""
|
||||
@@ -326,68 +297,63 @@ def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instructio
|
||||
"""
|
||||
try:
|
||||
run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
|
||||
|
||||
# --- Setup Logging for Rewrite ---
|
||||
|
||||
log_file = os.path.join(run_dir, "web_console.log")
|
||||
if not os.path.exists(log_file):
|
||||
log_file = os.path.join(project_path, f"system_log_{run_id}.txt")
|
||||
|
||||
# Clear previous logs to refresh the log window
|
||||
|
||||
try:
|
||||
with open(log_file, 'w', encoding='utf-8') as f: f.write("")
|
||||
except: pass
|
||||
|
||||
|
||||
utils.set_log_file(log_file)
|
||||
db_path = os.path.join(config.DATA_DIR, "bookapp.db")
|
||||
utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m))
|
||||
|
||||
|
||||
try:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("DELETE FROM log_entry WHERE run_id = ?", (run_id,))
|
||||
conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,))
|
||||
except: pass
|
||||
# ---------------------------------
|
||||
|
||||
book_path = os.path.join(run_dir, book_folder)
|
||||
|
||||
ms_path = os.path.join(book_path, "manuscript.json")
|
||||
bp_path = os.path.join(book_path, "final_blueprint.json")
|
||||
|
||||
|
||||
if not (os.path.exists(ms_path) and os.path.exists(bp_path)):
|
||||
utils.log("ERROR", f"Rewrite failed: files not found for run {run_id}/{book_folder}")
|
||||
return False
|
||||
|
||||
ms = utils.load_json(ms_path)
|
||||
bp = utils.load_json(bp_path)
|
||||
|
||||
ai.init_models()
|
||||
|
||||
result = story.rewrite_chapter_content(bp, ms, chap_num, instruction, book_path)
|
||||
|
||||
|
||||
ai_setup.init_models()
|
||||
|
||||
from story import editor as story_editor
|
||||
result = story_editor.rewrite_chapter_content(bp, ms, chap_num, instruction, book_path)
|
||||
|
||||
if result and result[0]:
|
||||
new_text, summary = result
|
||||
for ch in ms:
|
||||
if str(ch.get('num')) == str(chap_num):
|
||||
ch['content'] = new_text
|
||||
break
|
||||
|
||||
# Save the primary rewrite immediately
|
||||
|
||||
with open(ms_path, 'w') as f: json.dump(ms, f, indent=2)
|
||||
|
||||
updated_ms = story.check_and_propagate(bp, ms, chap_num, book_path, change_summary=summary)
|
||||
|
||||
updated_ms = story_editor.check_and_propagate(bp, ms, chap_num, book_path, change_summary=summary)
|
||||
if updated_ms:
|
||||
ms = updated_ms
|
||||
|
||||
with open(ms_path, 'w') as f: json.dump(ms, f, indent=2)
|
||||
export.compile_files(bp, ms, book_path)
|
||||
|
||||
exporter.compile_files(bp, ms, book_path)
|
||||
|
||||
try:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("UPDATE run SET status = 'completed' WHERE id = ?", (run_id,))
|
||||
except: pass
|
||||
return True
|
||||
|
||||
# If result is False/None
|
||||
|
||||
try:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("UPDATE run SET status = 'completed' WHERE id = ?", (run_id,))
|
||||
@@ -401,6 +367,7 @@ def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instructio
|
||||
except: pass
|
||||
return False
|
||||
|
||||
|
||||
@huey.task()
|
||||
def refine_bible_task(project_path, instruction, source_type, selected_keys=None):
|
||||
"""
|
||||
@@ -411,38 +378,32 @@ def refine_bible_task(project_path, instruction, source_type, selected_keys=None
|
||||
bible_path = os.path.join(project_path, "bible.json")
|
||||
draft_path = os.path.join(project_path, "bible_draft.json")
|
||||
lock_path = os.path.join(project_path, ".refining")
|
||||
|
||||
|
||||
with open(lock_path, 'w') as f: f.write("running")
|
||||
|
||||
|
||||
base_bible = utils.load_json(bible_path)
|
||||
if not base_bible: return False
|
||||
|
||||
# If refining from draft, load it
|
||||
if source_type == 'draft' and os.path.exists(draft_path):
|
||||
draft_bible = utils.load_json(draft_path)
|
||||
|
||||
# If user selected specific changes, merge them into the base
|
||||
# This creates a "Proposed State" to refine further, WITHOUT modifying bible.json
|
||||
|
||||
if selected_keys is not None and draft_bible:
|
||||
base_bible = story.merge_selected_changes(base_bible, draft_bible, selected_keys)
|
||||
base_bible = bible_tracker.merge_selected_changes(base_bible, draft_bible, selected_keys)
|
||||
elif draft_bible:
|
||||
# If no specific keys but source is draft, assume we refine the whole draft
|
||||
base_bible = draft_bible
|
||||
|
||||
ai.init_models()
|
||||
|
||||
# Run AI Refinement
|
||||
new_bible = story.refine_bible(base_bible, instruction, project_path)
|
||||
|
||||
ai_setup.init_models()
|
||||
|
||||
new_bible = bible_tracker.refine_bible(base_bible, instruction, project_path)
|
||||
|
||||
if new_bible:
|
||||
# Save to draft file (Overwrite previous draft)
|
||||
with open(draft_path, 'w') as f: json.dump(new_bible, f, indent=2)
|
||||
return True
|
||||
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
utils.log("ERROR", f"Bible refinement task failed: {e}")
|
||||
return False
|
||||
finally:
|
||||
if os.path.exists(lock_path): os.remove(lock_path)
|
||||
if os.path.exists(lock_path): os.remove(lock_path)
|
||||
Reference in New Issue
Block a user