Compare commits
2 Commits
edabc4d4fa
...
81353cf071
| Author | SHA1 | Date | |
|---|---|---|---|
| 81353cf071 | |||
| f7099cc3e4 |
24
.gitignore
vendored
24
.gitignore
vendored
@@ -6,3 +6,27 @@ run_*/
|
|||||||
data/
|
data/
|
||||||
token.json
|
token.json
|
||||||
credentials.json
|
credentials.json
|
||||||
|
|
||||||
|
# AI Blueprint and Context Files
|
||||||
|
ai_blueprint.md
|
||||||
|
plans/
|
||||||
|
|
||||||
|
# Claude / Anthropic Artifacts
|
||||||
|
.claude/
|
||||||
|
claude.json
|
||||||
|
|
||||||
|
# Gemini / Google Artifacts
|
||||||
|
.gemini/
|
||||||
|
gemini_history.json
|
||||||
|
|
||||||
|
# AI Coding Assistant Directories (Roo Code, Cline, Cursor, Windsurf)
|
||||||
|
.roo/
|
||||||
|
.cline/
|
||||||
|
.cursor/
|
||||||
|
.cursorrules
|
||||||
|
.windsurfrules
|
||||||
|
.cascade/
|
||||||
|
|
||||||
|
# AI Generated Index and Memory Cache Files
|
||||||
|
*.aiindex
|
||||||
|
ai_workspace_index.json
|
||||||
@@ -14,11 +14,11 @@ RUN apt-get update && apt-get install -y \
|
|||||||
|
|
||||||
# Copy requirements files
|
# Copy requirements files
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
COPY modules/requirements_web.txt ./modules/
|
COPY web/requirements_web.txt ./web/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
RUN pip install --no-cache-dir -r modules/requirements_web.txt
|
RUN pip install --no-cache-dir -r web/requirements_web.txt
|
||||||
|
|
||||||
# Copy the rest of the application
|
# Copy the rest of the application
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -28,4 +28,4 @@ ENV PYTHONPATH=/app
|
|||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/login')" || exit 1
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/login')" || exit 1
|
||||||
CMD ["python", "-m", "modules.web_app"]
|
CMD ["python", "-m", "web.app"]
|
||||||
@@ -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 time
|
||||||
import warnings
|
import warnings
|
||||||
import google.generativeai as genai
|
import google.generativeai as genai
|
||||||
import config
|
from core import config, utils
|
||||||
from . import utils
|
from ai import models
|
||||||
|
|
||||||
# Suppress Vertex AI warnings
|
|
||||||
warnings.filterwarnings("ignore", category=UserWarning, module="vertexai")
|
|
||||||
|
|
||||||
try:
|
|
||||||
import vertexai
|
|
||||||
from vertexai.preview.vision_models import ImageGenerationModel as VertexImageModel
|
|
||||||
HAS_VERTEX = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_VERTEX = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
from google.auth.transport.requests import Request
|
|
||||||
from google.oauth2.credentials import Credentials
|
|
||||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
||||||
HAS_OAUTH = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_OAUTH = False
|
|
||||||
|
|
||||||
model_logic = None
|
|
||||||
model_writer = None
|
|
||||||
model_artist = None
|
|
||||||
model_image = None
|
|
||||||
logic_model_name = "models/gemini-1.5-pro"
|
|
||||||
writer_model_name = "models/gemini-1.5-flash"
|
|
||||||
artist_model_name = "models/gemini-1.5-flash"
|
|
||||||
image_model_name = None
|
|
||||||
image_model_source = "None"
|
|
||||||
|
|
||||||
class ResilientModel:
|
|
||||||
def __init__(self, name, safety_settings, role):
|
|
||||||
self.name = name
|
|
||||||
self.safety_settings = safety_settings
|
|
||||||
self.role = role
|
|
||||||
self.model = genai.GenerativeModel(name, safety_settings=safety_settings)
|
|
||||||
|
|
||||||
def update(self, name):
|
|
||||||
self.name = name
|
|
||||||
self.model = genai.GenerativeModel(name, safety_settings=self.safety_settings)
|
|
||||||
|
|
||||||
def generate_content(self, *args, **kwargs):
|
|
||||||
retries = 0
|
|
||||||
max_retries = 3
|
|
||||||
base_delay = 5
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
return self.model.generate_content(*args, **kwargs)
|
|
||||||
except Exception as e:
|
|
||||||
err_str = str(e).lower()
|
|
||||||
is_retryable = "429" in err_str or "quota" in err_str or "500" in err_str or "503" in err_str or "504" in err_str or "deadline" in err_str or "internal error" in err_str
|
|
||||||
if is_retryable and retries < max_retries:
|
|
||||||
delay = base_delay * (2 ** retries)
|
|
||||||
utils.log("SYSTEM", f"⚠️ Quota error on {self.role} ({self.name}). Retrying in {delay}s...")
|
|
||||||
time.sleep(delay)
|
|
||||||
|
|
||||||
# On first retry, attempt to re-optimize/rotate models
|
|
||||||
if retries == 0:
|
|
||||||
utils.log("SYSTEM", "Attempting to re-optimize models to find alternative...")
|
|
||||||
init_models(force=True)
|
|
||||||
# Note: init_models calls .update() on this instance
|
|
||||||
|
|
||||||
retries += 1
|
|
||||||
continue
|
|
||||||
raise e
|
|
||||||
|
|
||||||
def get_optimal_model(base_type="pro"):
|
def get_optimal_model(base_type="pro"):
|
||||||
try:
|
try:
|
||||||
models = [m for m in genai.list_models() if 'generateContent' in m.supported_generation_methods]
|
available = [m for m in genai.list_models() if 'generateContent' in m.supported_generation_methods]
|
||||||
candidates = [m.name for m in models if base_type in m.name]
|
candidates = [m.name for m in available if base_type in m.name]
|
||||||
if not candidates: return f"models/gemini-1.5-{base_type}"
|
if not candidates: return f"models/gemini-1.5-{base_type}"
|
||||||
|
|
||||||
def score(n):
|
def score(n):
|
||||||
# Prefer newer generations: 2.5 > 2.0 > 1.5
|
|
||||||
gen_bonus = 0
|
gen_bonus = 0
|
||||||
if "2.5" in n: gen_bonus = 300
|
if "2.5" in n: gen_bonus = 300
|
||||||
elif "2.0" in n: gen_bonus = 200
|
elif "2.0" in n: gen_bonus = 200
|
||||||
elif "2." in n: gen_bonus = 150
|
elif "2." in n: gen_bonus = 150
|
||||||
# Within a generation, prefer stable over experimental
|
|
||||||
if "exp" in n or "beta" in n or "preview" in n: return gen_bonus + 0
|
if "exp" in n or "beta" in n or "preview" in n: return gen_bonus + 0
|
||||||
if "latest" in n: return gen_bonus + 50
|
if "latest" in n: return gen_bonus + 50
|
||||||
return gen_bonus + 100
|
return gen_bonus + 100
|
||||||
|
|
||||||
return sorted(candidates, key=score, reverse=True)[0]
|
return sorted(candidates, key=score, reverse=True)[0]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
utils.log("SYSTEM", f"⚠️ Error finding optimal model: {e}")
|
utils.log("SYSTEM", f"⚠️ Error finding optimal model: {e}")
|
||||||
return f"models/gemini-1.5-{base_type}"
|
return f"models/gemini-1.5-{base_type}"
|
||||||
|
|
||||||
|
|
||||||
def get_default_models():
|
def get_default_models():
|
||||||
return {
|
return {
|
||||||
"logic": {"model": "models/gemini-2.0-pro-exp", "reason": "Fallback: Gemini 2.0 Pro for complex reasoning and JSON adherence.", "estimated_cost": "$0.00/1M (Experimental)"},
|
"logic": {"model": "models/gemini-2.0-pro-exp", "reason": "Fallback: Gemini 2.0 Pro for complex reasoning and JSON adherence.", "estimated_cost": "$0.00/1M (Experimental)"},
|
||||||
@@ -99,27 +36,21 @@ def get_default_models():
|
|||||||
"ranking": []
|
"ranking": []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def select_best_models(force_refresh=False):
|
def select_best_models(force_refresh=False):
|
||||||
"""
|
|
||||||
Uses a safe bootstrapper model to analyze available models and pick the best ones.
|
|
||||||
Caches the result for 24 hours.
|
|
||||||
"""
|
|
||||||
cache_path = os.path.join(config.DATA_DIR, "model_cache.json")
|
cache_path = os.path.join(config.DATA_DIR, "model_cache.json")
|
||||||
cached_models = None
|
cached_models = None
|
||||||
|
|
||||||
# 1. Check Cache
|
|
||||||
if os.path.exists(cache_path):
|
if os.path.exists(cache_path):
|
||||||
try:
|
try:
|
||||||
with open(cache_path, 'r') as f:
|
with open(cache_path, 'r') as f:
|
||||||
cached = json.load(f)
|
cached = json.load(f)
|
||||||
cached_models = cached.get('models', {})
|
cached_models = cached.get('models', {})
|
||||||
# Check if within 24 hours (86400 seconds)
|
|
||||||
if not force_refresh and time.time() - cached.get('timestamp', 0) < 86400:
|
if not force_refresh and time.time() - cached.get('timestamp', 0) < 86400:
|
||||||
models = cached_models
|
m = cached_models
|
||||||
# Validate format (must be dicts with reasons, not just strings)
|
if isinstance(m.get('logic'), dict) and 'reason' in m['logic']:
|
||||||
if isinstance(models.get('logic'), dict) and 'reason' in models['logic']:
|
|
||||||
utils.log("SYSTEM", "Using cached AI model selection (valid for 24h).")
|
utils.log("SYSTEM", "Using cached AI model selection (valid for 24h).")
|
||||||
return models
|
return m
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
utils.log("SYSTEM", f"Cache read failed: {e}. Refreshing models.")
|
utils.log("SYSTEM", f"Cache read failed: {e}. Refreshing models.")
|
||||||
|
|
||||||
@@ -129,8 +60,8 @@ def select_best_models(force_refresh=False):
|
|||||||
raw_model_names = [m.name for m in all_models]
|
raw_model_names = [m.name for m in all_models]
|
||||||
utils.log("SYSTEM", f"Found {len(all_models)} raw models from Google API.")
|
utils.log("SYSTEM", f"Found {len(all_models)} raw models from Google API.")
|
||||||
|
|
||||||
models = [m.name for m in all_models if 'generateContent' in m.supported_generation_methods and 'gemini' in m.name.lower()]
|
compatible = [m.name for m in all_models if 'generateContent' in m.supported_generation_methods and 'gemini' in m.name.lower()]
|
||||||
utils.log("SYSTEM", f"Identified {len(models)} compatible Gemini models: {models}")
|
utils.log("SYSTEM", f"Identified {len(compatible)} compatible Gemini models: {compatible}")
|
||||||
|
|
||||||
bootstrapper = get_optimal_model("flash")
|
bootstrapper = get_optimal_model("flash")
|
||||||
utils.log("SYSTEM", f"Bootstrapping model selection with: {bootstrapper}")
|
utils.log("SYSTEM", f"Bootstrapping model selection with: {bootstrapper}")
|
||||||
@@ -141,7 +72,7 @@ def select_best_models(force_refresh=False):
|
|||||||
TASK: Select the optimal Gemini models for a book-writing application. Prefer newer Gemini 2.x models when available.
|
TASK: Select the optimal Gemini models for a book-writing application. Prefer newer Gemini 2.x models when available.
|
||||||
|
|
||||||
AVAILABLE_MODELS:
|
AVAILABLE_MODELS:
|
||||||
{json.dumps(models)}
|
{json.dumps(compatible)}
|
||||||
|
|
||||||
PRICING_CONTEXT (USD per 1M tokens, approximate):
|
PRICING_CONTEXT (USD per 1M tokens, approximate):
|
||||||
- Gemini 2.5 Pro/Flash: Best quality/speed; check current pricing.
|
- Gemini 2.5 Pro/Flash: Best quality/speed; check current pricing.
|
||||||
@@ -185,15 +116,14 @@ def select_best_models(force_refresh=False):
|
|||||||
json.dump({
|
json.dump({
|
||||||
"timestamp": int(time.time()),
|
"timestamp": int(time.time()),
|
||||||
"models": selection,
|
"models": selection,
|
||||||
"available_at_time": models,
|
"available_at_time": compatible,
|
||||||
"raw_models": raw_model_names
|
"raw_models": raw_model_names
|
||||||
}, f, indent=2)
|
}, f, indent=2)
|
||||||
return selection
|
return selection
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
utils.log("SYSTEM", f"AI Model Selection failed: {e}.")
|
utils.log("SYSTEM", f"AI Model Selection failed: {e}.")
|
||||||
|
|
||||||
# 3. Fallback to Stale Cache if available (Better than heuristics)
|
|
||||||
# Relaxed check: If we successfully loaded ANY JSON from the cache, use it.
|
|
||||||
if cached_models:
|
if cached_models:
|
||||||
utils.log("SYSTEM", "⚠️ Using stale cached models due to API failure.")
|
utils.log("SYSTEM", "⚠️ Using stale cached models due to API failure.")
|
||||||
return cached_models
|
return cached_models
|
||||||
@@ -201,20 +131,19 @@ def select_best_models(force_refresh=False):
|
|||||||
utils.log("SYSTEM", "Falling back to heuristics.")
|
utils.log("SYSTEM", "Falling back to heuristics.")
|
||||||
fallback = get_default_models()
|
fallback = get_default_models()
|
||||||
|
|
||||||
# Save fallback to cache if file doesn't exist OR if we couldn't load it (corrupt/None)
|
|
||||||
# This ensures we have a valid file on disk for the web UI to read.
|
|
||||||
try:
|
try:
|
||||||
with open(cache_path, 'w') as f:
|
with open(cache_path, 'w') as f:
|
||||||
json.dump({"timestamp": int(time.time()), "models": fallback, "error": str(e)}, f, indent=2)
|
json.dump({"timestamp": int(time.time()), "models": fallback, "error": str(e)}, f, indent=2)
|
||||||
except: pass
|
except: pass
|
||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
def init_models(force=False):
|
def init_models(force=False):
|
||||||
global model_logic, model_writer, model_artist, model_image, logic_model_name, writer_model_name, artist_model_name, image_model_name, image_model_source
|
global_vars = models.__dict__
|
||||||
if model_logic and not force: return
|
if global_vars.get('model_logic') and not force: return
|
||||||
|
|
||||||
genai.configure(api_key=config.API_KEY)
|
genai.configure(api_key=config.API_KEY)
|
||||||
|
|
||||||
# Check cache to skip frequent validation
|
|
||||||
cache_path = os.path.join(config.DATA_DIR, "model_cache.json")
|
cache_path = os.path.join(config.DATA_DIR, "model_cache.json")
|
||||||
skip_validation = False
|
skip_validation = False
|
||||||
if not force and os.path.exists(cache_path):
|
if not force and os.path.exists(cache_path):
|
||||||
@@ -224,13 +153,11 @@ def init_models(force=False):
|
|||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
if not skip_validation:
|
if not skip_validation:
|
||||||
# Validate Gemini API Key
|
|
||||||
utils.log("SYSTEM", "Validating credentials...")
|
utils.log("SYSTEM", "Validating credentials...")
|
||||||
try:
|
try:
|
||||||
list(genai.list_models(page_size=1))
|
list(genai.list_models(page_size=1))
|
||||||
utils.log("SYSTEM", "✅ Gemini API Key is valid.")
|
utils.log("SYSTEM", "✅ Gemini API Key is valid.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Check if we have a cache file we can rely on before exiting
|
|
||||||
if os.path.exists(cache_path):
|
if os.path.exists(cache_path):
|
||||||
utils.log("SYSTEM", f"⚠️ API check failed ({e}), but cache exists. Attempting to use cached models.")
|
utils.log("SYSTEM", f"⚠️ API check failed ({e}), but cache exists. Attempting to use cached models.")
|
||||||
else:
|
else:
|
||||||
@@ -239,7 +166,6 @@ def init_models(force=False):
|
|||||||
utils.log("SYSTEM", "Selecting optimal models via AI...")
|
utils.log("SYSTEM", "Selecting optimal models via AI...")
|
||||||
selected_models = select_best_models(force_refresh=force)
|
selected_models = select_best_models(force_refresh=force)
|
||||||
|
|
||||||
# Check for missing costs and force refresh if needed
|
|
||||||
if not force:
|
if not force:
|
||||||
missing_costs = False
|
missing_costs = False
|
||||||
for role in ['logic', 'writer', 'artist']:
|
for role in ['logic', 'writer', 'artist']:
|
||||||
@@ -257,57 +183,52 @@ def init_models(force=False):
|
|||||||
writer_name, writer_cost = get_model_details(selected_models['writer'])
|
writer_name, writer_cost = get_model_details(selected_models['writer'])
|
||||||
artist_name, artist_cost = get_model_details(selected_models['artist'])
|
artist_name, artist_cost = get_model_details(selected_models['artist'])
|
||||||
|
|
||||||
logic_name = logic_model_name = logic_name if config.MODEL_LOGIC_HINT == "AUTO" else config.MODEL_LOGIC_HINT
|
logic_name = logic_name if config.MODEL_LOGIC_HINT == "AUTO" else config.MODEL_LOGIC_HINT
|
||||||
writer_name = writer_model_name = writer_name if config.MODEL_WRITER_HINT == "AUTO" else config.MODEL_WRITER_HINT
|
writer_name = writer_name if config.MODEL_WRITER_HINT == "AUTO" else config.MODEL_WRITER_HINT
|
||||||
artist_name = artist_model_name = artist_name if config.MODEL_ARTIST_HINT == "AUTO" else config.MODEL_ARTIST_HINT
|
artist_name = artist_name if config.MODEL_ARTIST_HINT == "AUTO" else config.MODEL_ARTIST_HINT
|
||||||
|
|
||||||
|
models.logic_model_name = logic_name
|
||||||
|
models.writer_model_name = writer_name
|
||||||
|
models.artist_model_name = artist_name
|
||||||
|
|
||||||
utils.log("SYSTEM", f"Models: Logic={logic_name} ({logic_cost}) | Writer={writer_name} ({writer_cost}) | Artist={artist_name}")
|
utils.log("SYSTEM", f"Models: Logic={logic_name} ({logic_cost}) | Writer={writer_name} ({writer_cost}) | Artist={artist_name}")
|
||||||
|
|
||||||
# Update pricing in utils
|
|
||||||
utils.update_pricing(logic_name, logic_cost)
|
utils.update_pricing(logic_name, logic_cost)
|
||||||
utils.update_pricing(writer_name, writer_cost)
|
utils.update_pricing(writer_name, writer_cost)
|
||||||
utils.update_pricing(artist_name, artist_cost)
|
utils.update_pricing(artist_name, artist_cost)
|
||||||
|
|
||||||
# Initialize or Update Resilient Models
|
if models.model_logic is None:
|
||||||
if model_logic is None:
|
models.model_logic = models.ResilientModel(logic_name, utils.SAFETY_SETTINGS, "Logic")
|
||||||
model_logic = ResilientModel(logic_name, utils.SAFETY_SETTINGS, "Logic")
|
models.model_writer = models.ResilientModel(writer_name, utils.SAFETY_SETTINGS, "Writer")
|
||||||
model_writer = ResilientModel(writer_name, utils.SAFETY_SETTINGS, "Writer")
|
models.model_artist = models.ResilientModel(artist_name, utils.SAFETY_SETTINGS, "Artist")
|
||||||
model_artist = ResilientModel(artist_name, utils.SAFETY_SETTINGS, "Artist")
|
|
||||||
else:
|
else:
|
||||||
# If models already exist (re-init), update them in place
|
models.model_logic.update(logic_name)
|
||||||
model_logic.update(logic_name)
|
models.model_writer.update(writer_name)
|
||||||
model_writer.update(writer_name)
|
models.model_artist.update(artist_name)
|
||||||
model_artist.update(artist_name)
|
|
||||||
|
|
||||||
# Initialize Image Model
|
models.model_image = None
|
||||||
model_image = None
|
models.image_model_name = None
|
||||||
image_model_name = None
|
models.image_model_source = "None"
|
||||||
image_model_source = "None"
|
|
||||||
|
|
||||||
hint = config.MODEL_IMAGE_HINT if hasattr(config, 'MODEL_IMAGE_HINT') else "AUTO"
|
hint = config.MODEL_IMAGE_HINT if hasattr(config, 'MODEL_IMAGE_HINT') else "AUTO"
|
||||||
|
|
||||||
if hasattr(genai, 'ImageGenerationModel'):
|
if hasattr(genai, 'ImageGenerationModel'):
|
||||||
# Candidate image models in preference order
|
candidates = [hint] if hint and hint != "AUTO" else ["imagen-3.0-generate-001", "imagen-3.0-fast-generate-001"]
|
||||||
if hint and hint != "AUTO":
|
|
||||||
candidates = [hint]
|
|
||||||
else:
|
|
||||||
candidates = ["imagen-3.0-generate-001", "imagen-3.0-fast-generate-001"]
|
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
try:
|
try:
|
||||||
model_image = genai.ImageGenerationModel(candidate)
|
models.model_image = genai.ImageGenerationModel(candidate)
|
||||||
image_model_name = candidate
|
models.image_model_name = candidate
|
||||||
image_model_source = "Gemini API"
|
models.image_model_source = "Gemini API"
|
||||||
utils.log("SYSTEM", f"✅ Image model: {candidate} (Gemini API)")
|
utils.log("SYSTEM", f"✅ Image model: {candidate} (Gemini API)")
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Auto-detect GCP Project from credentials if not set (Fix for Image Model)
|
# Auto-detect GCP Project
|
||||||
if HAS_VERTEX and not config.GCP_PROJECT and config.GOOGLE_CREDS and os.path.exists(config.GOOGLE_CREDS):
|
if models.HAS_VERTEX and not config.GCP_PROJECT and config.GOOGLE_CREDS and os.path.exists(config.GOOGLE_CREDS):
|
||||||
try:
|
try:
|
||||||
with open(config.GOOGLE_CREDS, 'r') as f:
|
with open(config.GOOGLE_CREDS, 'r') as f:
|
||||||
cdata = json.load(f)
|
cdata = json.load(f)
|
||||||
# Check common OAuth structures
|
|
||||||
for k in ['installed', 'web']:
|
for k in ['installed', 'web']:
|
||||||
if k in cdata and 'project_id' in cdata[k]:
|
if k in cdata and 'project_id' in cdata[k]:
|
||||||
config.GCP_PROJECT = cdata[k]['project_id']
|
config.GCP_PROJECT = cdata[k]['project_id']
|
||||||
@@ -315,16 +236,14 @@ def init_models(force=False):
|
|||||||
break
|
break
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
if HAS_VERTEX and config.GCP_PROJECT:
|
if models.HAS_VERTEX and config.GCP_PROJECT:
|
||||||
creds = None
|
creds = None
|
||||||
# Handle OAuth Client ID (credentials.json) if provided instead of Service Account
|
if models.HAS_OAUTH:
|
||||||
if HAS_OAUTH:
|
gac = config.GOOGLE_CREDS
|
||||||
gac = config.GOOGLE_CREDS # Use persistent config, not volatile env var
|
|
||||||
if gac and os.path.exists(gac):
|
if gac and os.path.exists(gac):
|
||||||
try:
|
try:
|
||||||
with open(gac, 'r') as f: data = json.load(f)
|
with open(gac, 'r') as f: data = json.load(f)
|
||||||
if 'installed' in data or 'web' in data:
|
if 'installed' in data or 'web' in data:
|
||||||
# It's an OAuth Client ID. Unset env var to avoid library crash.
|
|
||||||
if "GOOGLE_APPLICATION_CREDENTIALS" in os.environ:
|
if "GOOGLE_APPLICATION_CREDENTIALS" in os.environ:
|
||||||
del os.environ["GOOGLE_APPLICATION_CREDENTIALS"]
|
del os.environ["GOOGLE_APPLICATION_CREDENTIALS"]
|
||||||
|
|
||||||
@@ -332,19 +251,19 @@ def init_models(force=False):
|
|||||||
SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
|
SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
|
||||||
|
|
||||||
if os.path.exists(token_path):
|
if os.path.exists(token_path):
|
||||||
creds = Credentials.from_authorized_user_file(token_path, SCOPES)
|
creds = models.Credentials.from_authorized_user_file(token_path, SCOPES)
|
||||||
|
|
||||||
if not creds or not creds.valid:
|
if not creds or not creds.valid:
|
||||||
if creds and creds.expired and creds.refresh_token:
|
if creds and creds.expired and creds.refresh_token:
|
||||||
try:
|
try:
|
||||||
creds.refresh(Request())
|
creds.refresh(models.Request())
|
||||||
except Exception:
|
except Exception:
|
||||||
utils.log("SYSTEM", "Token refresh failed. Re-authenticating...")
|
utils.log("SYSTEM", "Token refresh failed. Re-authenticating...")
|
||||||
flow = InstalledAppFlow.from_client_secrets_file(gac, SCOPES)
|
flow = models.InstalledAppFlow.from_client_secrets_file(gac, SCOPES)
|
||||||
creds = flow.run_local_server(port=0)
|
creds = flow.run_local_server(port=0)
|
||||||
else:
|
else:
|
||||||
utils.log("SYSTEM", "OAuth Client ID detected. Launching browser to authenticate...")
|
utils.log("SYSTEM", "OAuth Client ID detected. Launching browser to authenticate...")
|
||||||
flow = InstalledAppFlow.from_client_secrets_file(gac, SCOPES)
|
flow = models.InstalledAppFlow.from_client_secrets_file(gac, SCOPES)
|
||||||
creds = flow.run_local_server(port=0)
|
creds = flow.run_local_server(port=0)
|
||||||
with open(token_path, 'w') as token: token.write(creds.to_json())
|
with open(token_path, 'w') as token: token.write(creds.to_json())
|
||||||
|
|
||||||
@@ -352,21 +271,19 @@ def init_models(force=False):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
utils.log("SYSTEM", f"⚠️ OAuth check failed: {e}")
|
utils.log("SYSTEM", f"⚠️ OAuth check failed: {e}")
|
||||||
|
|
||||||
vertexai.init(project=config.GCP_PROJECT, location=config.GCP_LOCATION, credentials=creds)
|
import vertexai as _vertexai
|
||||||
|
_vertexai.init(project=config.GCP_PROJECT, location=config.GCP_LOCATION, credentials=creds)
|
||||||
utils.log("SYSTEM", f"✅ Vertex AI initialized (Project: {config.GCP_PROJECT})")
|
utils.log("SYSTEM", f"✅ Vertex AI initialized (Project: {config.GCP_PROJECT})")
|
||||||
|
|
||||||
# Override with Vertex Image Model if available
|
vertex_candidates = [hint] if hint and hint != "AUTO" else ["imagen-3.0-generate-001", "imagen-3.0-fast-generate-001"]
|
||||||
vertex_candidates = ["imagen-3.0-generate-001", "imagen-3.0-fast-generate-001"]
|
|
||||||
if hint and hint != "AUTO":
|
|
||||||
vertex_candidates = [hint]
|
|
||||||
for candidate in vertex_candidates:
|
for candidate in vertex_candidates:
|
||||||
try:
|
try:
|
||||||
model_image = VertexImageModel.from_pretrained(candidate)
|
models.model_image = models.VertexImageModel.from_pretrained(candidate)
|
||||||
image_model_name = candidate
|
models.image_model_name = candidate
|
||||||
image_model_source = "Vertex AI"
|
models.image_model_source = "Vertex AI"
|
||||||
utils.log("SYSTEM", f"✅ Image model: {candidate} (Vertex AI)")
|
utils.log("SYSTEM", f"✅ Image model: {candidate} (Vertex AI)")
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
utils.log("SYSTEM", f"Image Generation Provider: {image_model_source} ({image_model_name or 'unavailable'})")
|
utils.log("SYSTEM", f"Image Generation Provider: {models.image_model_source} ({models.image_model_name or 'unavailable'})")
|
||||||
0
cli/__init__.py
Normal file
0
cli/__init__.py
Normal file
@@ -1,7 +1,17 @@
|
|||||||
import json, os, time, sys, shutil
|
import json
|
||||||
import config
|
import os
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
from rich.prompt import Confirm
|
from rich.prompt import Confirm
|
||||||
from modules import ai, story, marketing, export, utils
|
from core import config, utils
|
||||||
|
from ai import models as ai_models
|
||||||
|
from ai import setup as ai_setup
|
||||||
|
from story import planner, writer as story_writer, editor as story_editor
|
||||||
|
from story import style_persona, bible_tracker
|
||||||
|
from marketing import assets as marketing_assets
|
||||||
|
from export import exporter
|
||||||
|
|
||||||
|
|
||||||
def process_book(bp, folder, context="", resume=False, interactive=False):
|
def process_book(bp, folder, context="", resume=False, interactive=False):
|
||||||
# Create lock file to indicate active processing
|
# Create lock file to indicate active processing
|
||||||
@@ -14,8 +24,6 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
# 1. Check completion
|
# 1. Check completion
|
||||||
if resume and os.path.exists(os.path.join(folder, "final_blueprint.json")):
|
if resume and os.path.exists(os.path.join(folder, "final_blueprint.json")):
|
||||||
utils.log("SYSTEM", f"Book in {folder} already finished. Skipping.")
|
utils.log("SYSTEM", f"Book in {folder} already finished. Skipping.")
|
||||||
|
|
||||||
# Clean up zombie lock file if it exists
|
|
||||||
if os.path.exists(lock_path): os.remove(lock_path)
|
if os.path.exists(lock_path): os.remove(lock_path)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -26,7 +34,6 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
if resume and os.path.exists(bp_path):
|
if resume and os.path.exists(bp_path):
|
||||||
utils.log("RESUME", "Loading existing blueprint...")
|
utils.log("RESUME", "Loading existing blueprint...")
|
||||||
saved_bp = utils.load_json(bp_path)
|
saved_bp = utils.load_json(bp_path)
|
||||||
# Merge latest metadata from Bible (passed in bp) into saved blueprint
|
|
||||||
if saved_bp:
|
if saved_bp:
|
||||||
if 'book_metadata' in bp and 'book_metadata' in saved_bp:
|
if 'book_metadata' in bp and 'book_metadata' in saved_bp:
|
||||||
for k in ['title', 'author', 'genre', 'target_audience', 'style', 'author_bio', 'author_details']:
|
for k in ['title', 'author', 'genre', 'target_audience', 'style', 'author_bio', 'author_details']:
|
||||||
@@ -37,12 +44,12 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
bp = saved_bp
|
bp = saved_bp
|
||||||
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
||||||
else:
|
else:
|
||||||
bp = story.enrich(bp, folder, context)
|
bp = planner.enrich(bp, folder, context)
|
||||||
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
||||||
|
|
||||||
# Ensure Persona Exists (Auto-create if missing)
|
# Ensure Persona Exists (Auto-create if missing)
|
||||||
if 'author_details' not in bp['book_metadata'] or not bp['book_metadata']['author_details']:
|
if 'author_details' not in bp['book_metadata'] or not bp['book_metadata']['author_details']:
|
||||||
bp['book_metadata']['author_details'] = story.create_initial_persona(bp, folder)
|
bp['book_metadata']['author_details'] = style_persona.create_initial_persona(bp, folder)
|
||||||
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
||||||
|
|
||||||
utils.log("TIMING", f"Blueprint Phase: {time.time() - t_step:.1f}s")
|
utils.log("TIMING", f"Blueprint Phase: {time.time() - t_step:.1f}s")
|
||||||
@@ -55,11 +62,11 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
utils.log("RESUME", "Loading existing events...")
|
utils.log("RESUME", "Loading existing events...")
|
||||||
events = utils.load_json(events_path)
|
events = utils.load_json(events_path)
|
||||||
else:
|
else:
|
||||||
events = story.plan_structure(bp, folder)
|
events = planner.plan_structure(bp, folder)
|
||||||
depth = bp['length_settings']['depth']
|
depth = bp['length_settings']['depth']
|
||||||
target_chaps = bp['length_settings']['chapters']
|
target_chaps = bp['length_settings']['chapters']
|
||||||
for d in range(1, depth+1):
|
for d in range(1, depth+1):
|
||||||
events = story.expand(events, d, target_chaps, bp, folder)
|
events = planner.expand(events, d, target_chaps, bp, folder)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
with open(events_path, "w") as f: json.dump(events, f, indent=2)
|
with open(events_path, "w") as f: json.dump(events, f, indent=2)
|
||||||
utils.log("TIMING", f"Structure & Expansion: {time.time() - t_step:.1f}s")
|
utils.log("TIMING", f"Structure & Expansion: {time.time() - t_step:.1f}s")
|
||||||
@@ -72,7 +79,7 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
utils.log("RESUME", "Loading existing chapter plan...")
|
utils.log("RESUME", "Loading existing chapter plan...")
|
||||||
chapters = utils.load_json(chapters_path)
|
chapters = utils.load_json(chapters_path)
|
||||||
else:
|
else:
|
||||||
chapters = story.create_chapter_plan(events, bp, folder)
|
chapters = planner.create_chapter_plan(events, bp, folder)
|
||||||
with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2)
|
with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2)
|
||||||
utils.log("TIMING", f"Chapter Planning: {time.time() - t_step:.1f}s")
|
utils.log("TIMING", f"Chapter Planning: {time.time() - t_step:.1f}s")
|
||||||
|
|
||||||
@@ -97,12 +104,11 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
|
|
||||||
summary = "The story begins."
|
summary = "The story begins."
|
||||||
if ms:
|
if ms:
|
||||||
# Efficient rebuild: first chapter (setup) + last 4 (recent events) avoids huge prompts
|
|
||||||
utils.log("RESUME", f"Rebuilding story context from {len(ms)} existing chapters...")
|
utils.log("RESUME", f"Rebuilding story context from {len(ms)} existing chapters...")
|
||||||
try:
|
try:
|
||||||
selected = ms[:1] + ms[-4:] if len(ms) > 5 else ms
|
selected = ms[:1] + ms[-4:] if len(ms) > 5 else ms
|
||||||
combined_text = "\n".join([f"Chapter {c['num']}: {c['content'][:3000]}" for c in selected])
|
combined_text = "\n".join([f"Chapter {c['num']}: {c['content'][:3000]}" for c in selected])
|
||||||
resp_sum = ai.model_writer.generate_content(f"""
|
resp_sum = ai_models.model_writer.generate_content(f"""
|
||||||
ROLE: Series Historian
|
ROLE: Series Historian
|
||||||
TASK: Create a cumulative 'Story So Far' summary.
|
TASK: Create a cumulative 'Story So Far' summary.
|
||||||
INPUT_TEXT:
|
INPUT_TEXT:
|
||||||
@@ -110,7 +116,7 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
INSTRUCTIONS: Use dense, factual bullet points. Focus on character meetings, relationships, and known information.
|
INSTRUCTIONS: Use dense, factual bullet points. Focus on character meetings, relationships, and known information.
|
||||||
OUTPUT: Summary text.
|
OUTPUT: Summary text.
|
||||||
""")
|
""")
|
||||||
utils.log_usage(folder, ai.model_writer.name, resp_sum.usage_metadata)
|
utils.log_usage(folder, ai_models.model_writer.name, resp_sum.usage_metadata)
|
||||||
summary = resp_sum.text
|
summary = resp_sum.text
|
||||||
except: summary = "The story continues."
|
except: summary = "The story continues."
|
||||||
|
|
||||||
@@ -126,28 +132,25 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
# Check for stop signal from Web UI
|
# Check for stop signal from Web UI
|
||||||
run_dir = os.path.dirname(folder)
|
run_dir = os.path.dirname(folder)
|
||||||
if os.path.exists(os.path.join(run_dir, ".stop")):
|
if os.path.exists(os.path.join(run_dir, ".stop")):
|
||||||
utils.log("SYSTEM", "🛑 Stop signal detected. Aborting generation.")
|
utils.log("SYSTEM", "Stop signal detected. Aborting generation.")
|
||||||
break
|
break
|
||||||
|
|
||||||
# Robust Resume: Check if this specific chapter number is already in the manuscript
|
# Robust Resume: Check if this specific chapter number is already in the manuscript
|
||||||
# (Handles cases where plan changed or ms is out of sync with index)
|
|
||||||
if any(str(c.get('num')) == str(ch['chapter_number']) for c in ms):
|
if any(str(c.get('num')) == str(ch['chapter_number']) for c in ms):
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Progress Banner — update bar and log chapter header before writing begins
|
# Progress Banner
|
||||||
utils.update_progress(15 + int((i / len(chapters)) * 75))
|
utils.update_progress(15 + int((i / len(chapters)) * 75))
|
||||||
utils.log_banner("WRITER", f"Chapter {ch['chapter_number']}/{len(chapters)}: {ch['title']}")
|
utils.log_banner("WRITER", f"Chapter {ch['chapter_number']}/{len(chapters)}: {ch['title']}")
|
||||||
|
|
||||||
# Pass previous chapter content for continuity if available
|
|
||||||
prev_content = ms[-1]['content'] if ms else None
|
prev_content = ms[-1]['content'] if ms else None
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Cap summary to most-recent 8000 chars; pass next chapter title as hook hint
|
|
||||||
summary_ctx = summary[-8000:] if len(summary) > 8000 else summary
|
summary_ctx = summary[-8000:] if len(summary) > 8000 else summary
|
||||||
next_hint = chapters[i+1]['title'] if i + 1 < len(chapters) else ""
|
next_hint = chapters[i+1]['title'] if i + 1 < len(chapters) else ""
|
||||||
txt = story.write_chapter(ch, bp, folder, summary_ctx, tracking, prev_content, next_chapter_hint=next_hint)
|
txt = story_writer.write_chapter(ch, bp, folder, summary_ctx, tracking, prev_content, next_chapter_hint=next_hint)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
utils.log("SYSTEM", f"Chapter generation failed: {e}")
|
utils.log("SYSTEM", f"Chapter generation failed: {e}")
|
||||||
if interactive:
|
if interactive:
|
||||||
@@ -164,12 +167,12 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Refine Persona to match the actual output (every 5 chapters to save API calls)
|
# Refine Persona to match the actual output (every 5 chapters)
|
||||||
if (i == 0 or i % 5 == 0) and txt:
|
if (i == 0 or i % 5 == 0) and txt:
|
||||||
bp['book_metadata']['author_details'] = story.refine_persona(bp, txt, folder)
|
bp['book_metadata']['author_details'] = style_persona.refine_persona(bp, txt, folder)
|
||||||
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
||||||
|
|
||||||
# Look ahead for context to ensure relevant details are captured
|
# Look ahead for context
|
||||||
next_info = ""
|
next_info = ""
|
||||||
if i + 1 < len(chapters):
|
if i + 1 < len(chapters):
|
||||||
next_ch = chapters[i+1]
|
next_ch = chapters[i+1]
|
||||||
@@ -195,13 +198,13 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
|
|
||||||
OUTPUT: Updated summary text.
|
OUTPUT: Updated summary text.
|
||||||
"""
|
"""
|
||||||
resp_sum = ai.model_writer.generate_content(update_prompt)
|
resp_sum = ai_models.model_writer.generate_content(update_prompt)
|
||||||
utils.log_usage(folder, ai.model_writer.name, resp_sum.usage_metadata)
|
utils.log_usage(folder, ai_models.model_writer.name, resp_sum.usage_metadata)
|
||||||
summary = resp_sum.text
|
summary = resp_sum.text
|
||||||
except:
|
except:
|
||||||
try:
|
try:
|
||||||
resp_fallback = ai.model_writer.generate_content(f"ROLE: Summarizer\nTASK: Summarize plot points.\nTEXT: {txt}\nOUTPUT: Bullet points.")
|
resp_fallback = ai_models.model_writer.generate_content(f"ROLE: Summarizer\nTASK: Summarize plot points.\nTEXT: {txt}\nOUTPUT: Bullet points.")
|
||||||
utils.log_usage(folder, ai.model_writer.name, resp_fallback.usage_metadata)
|
utils.log_usage(folder, ai_models.model_writer.name, resp_fallback.usage_metadata)
|
||||||
summary += f"\n\nChapter {ch['chapter_number']}: " + resp_fallback.text
|
summary += f"\n\nChapter {ch['chapter_number']}: " + resp_fallback.text
|
||||||
except: summary += f"\n\nChapter {ch['chapter_number']}: [Content processed]"
|
except: summary += f"\n\nChapter {ch['chapter_number']}: [Content processed]"
|
||||||
|
|
||||||
@@ -210,18 +213,17 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
with open(ms_path, "w") as f: json.dump(ms, f, indent=2)
|
with open(ms_path, "w") as f: json.dump(ms, f, indent=2)
|
||||||
|
|
||||||
# Update Tracking
|
# Update Tracking
|
||||||
tracking = story.update_tracking(folder, ch['chapter_number'], txt, tracking)
|
tracking = bible_tracker.update_tracking(folder, ch['chapter_number'], txt, tracking)
|
||||||
with open(events_track_path, "w") as f: json.dump(tracking['events'], f, indent=2)
|
with open(events_track_path, "w") as f: json.dump(tracking['events'], f, indent=2)
|
||||||
with open(chars_track_path, "w") as f: json.dump(tracking['characters'], f, indent=2)
|
with open(chars_track_path, "w") as f: json.dump(tracking['characters'], f, indent=2)
|
||||||
with open(warn_track_path, "w") as f: json.dump(tracking.get('content_warnings', []), f, indent=2)
|
with open(warn_track_path, "w") as f: json.dump(tracking.get('content_warnings', []), f, indent=2)
|
||||||
|
|
||||||
# --- DYNAMIC PACING CHECK (every other chapter to halve API overhead) ---
|
# Dynamic Pacing Check (every other chapter)
|
||||||
remaining = chapters[i+1:]
|
remaining = chapters[i+1:]
|
||||||
if remaining and len(remaining) >= 2 and i % 2 == 1:
|
if remaining and len(remaining) >= 2 and i % 2 == 1:
|
||||||
pacing = story.check_pacing(bp, summary, txt, ch, remaining, folder)
|
pacing = story_editor.check_pacing(bp, summary, txt, ch, remaining, folder)
|
||||||
if pacing and pacing.get('status') == 'add_bridge':
|
if pacing and pacing.get('status') == 'add_bridge':
|
||||||
new_data = pacing.get('new_chapter', {})
|
new_data = pacing.get('new_chapter', {})
|
||||||
# Estimate bridge chapter length from current plan average (not hardcoded)
|
|
||||||
if chapters:
|
if chapters:
|
||||||
avg_words = int(sum(c.get('estimated_words', 1500) for c in chapters) / len(chapters))
|
avg_words = int(sum(c.get('estimated_words', 1500) for c in chapters) / len(chapters))
|
||||||
else:
|
else:
|
||||||
@@ -235,19 +237,15 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
"beats": new_data.get('beats', [])
|
"beats": new_data.get('beats', [])
|
||||||
}
|
}
|
||||||
chapters.insert(i+1, new_ch)
|
chapters.insert(i+1, new_ch)
|
||||||
# Renumber subsequent chapters
|
|
||||||
for k in range(i+1, len(chapters)): chapters[k]['chapter_number'] = k + 1
|
for k in range(i+1, len(chapters)): chapters[k]['chapter_number'] = k + 1
|
||||||
|
|
||||||
with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2)
|
with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2)
|
||||||
utils.log("ARCHITECT", f" -> ⚠️ Pacing Intervention: Added bridge chapter '{new_ch['title']}' to fix rushing.")
|
utils.log("ARCHITECT", f" -> Pacing Intervention: Added bridge chapter '{new_ch['title']}' to fix rushing.")
|
||||||
|
|
||||||
elif pacing and pacing.get('status') == 'cut_next':
|
elif pacing and pacing.get('status') == 'cut_next':
|
||||||
removed = chapters.pop(i+1)
|
removed = chapters.pop(i+1)
|
||||||
# Renumber subsequent chapters
|
|
||||||
for k in range(i+1, len(chapters)): chapters[k]['chapter_number'] = k + 1
|
for k in range(i+1, len(chapters)): chapters[k]['chapter_number'] = k + 1
|
||||||
|
|
||||||
with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2)
|
with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2)
|
||||||
utils.log("ARCHITECT", f" -> ⚠️ Pacing Intervention: Removed redundant chapter '{removed['title']}'.")
|
utils.log("ARCHITECT", f" -> Pacing Intervention: Removed redundant chapter '{removed['title']}'.")
|
||||||
elif pacing:
|
elif pacing:
|
||||||
utils.log("ARCHITECT", f" -> Pacing OK. {pacing.get('reason', '')[:100]}")
|
utils.log("ARCHITECT", f" -> Pacing OK. {pacing.get('reason', '')[:100]}")
|
||||||
|
|
||||||
@@ -260,7 +258,6 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
avg_time = session_time / session_chapters
|
avg_time = session_time / session_chapters
|
||||||
eta = avg_time * (len(chapters) - (i + 1))
|
eta = avg_time * (len(chapters) - (i + 1))
|
||||||
|
|
||||||
# Calculate Progress (15% to 90%)
|
|
||||||
prog = 15 + int((i / len(chapters)) * 75)
|
prog = 15 + int((i / len(chapters)) * 75)
|
||||||
utils.update_progress(prog)
|
utils.update_progress(prog)
|
||||||
|
|
||||||
@@ -272,28 +269,27 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
# Harvest
|
# Harvest
|
||||||
t_step = time.time()
|
t_step = time.time()
|
||||||
utils.update_progress(92)
|
utils.update_progress(92)
|
||||||
bp = story.harvest_metadata(bp, folder, ms)
|
bp = bible_tracker.harvest_metadata(bp, folder, ms)
|
||||||
with open(os.path.join(folder, "final_blueprint.json"), "w") as f: json.dump(bp, f, indent=2)
|
with open(os.path.join(folder, "final_blueprint.json"), "w") as f: json.dump(bp, f, indent=2)
|
||||||
|
|
||||||
# Create Assets
|
# Create Assets
|
||||||
utils.update_progress(95)
|
utils.update_progress(95)
|
||||||
marketing.create_marketing_assets(bp, folder, tracking, interactive=interactive)
|
marketing_assets.create_marketing_assets(bp, folder, tracking, interactive=interactive)
|
||||||
|
|
||||||
# Update Persona
|
# Update Persona
|
||||||
story.update_persona_sample(bp, folder)
|
style_persona.update_persona_sample(bp, folder)
|
||||||
|
|
||||||
utils.update_progress(98)
|
utils.update_progress(98)
|
||||||
export.compile_files(bp, ms, folder)
|
exporter.compile_files(bp, ms, folder)
|
||||||
utils.log("TIMING", f"Post-Processing: {time.time() - t_step:.1f}s")
|
utils.log("TIMING", f"Post-Processing: {time.time() - t_step:.1f}s")
|
||||||
utils.log("SYSTEM", f"Book Finished. Total Time: {time.time() - total_start:.1f}s")
|
utils.log("SYSTEM", f"Book Finished. Total Time: {time.time() - total_start:.1f}s")
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Remove lock file on success or failure
|
|
||||||
if os.path.exists(lock_path): os.remove(lock_path)
|
if os.path.exists(lock_path): os.remove(lock_path)
|
||||||
|
|
||||||
# --- 6. ENTRY POINT ---
|
|
||||||
def run_generation(target=None, specific_run_id=None, interactive=False):
|
def run_generation(target=None, specific_run_id=None, interactive=False):
|
||||||
ai.init_models()
|
ai_setup.init_models()
|
||||||
|
|
||||||
if not target: target = config.DEFAULT_BLUEPRINT
|
if not target: target = config.DEFAULT_BLUEPRINT
|
||||||
data = utils.load_json(target)
|
data = utils.load_json(target)
|
||||||
@@ -302,10 +298,8 @@ def run_generation(target=None, specific_run_id=None, interactive=False):
|
|||||||
utils.log("SYSTEM", f"Could not load {target}")
|
utils.log("SYSTEM", f"Could not load {target}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# --- BIBLE FORMAT ---
|
|
||||||
utils.log("SYSTEM", "Starting Series Generation...")
|
utils.log("SYSTEM", "Starting Series Generation...")
|
||||||
|
|
||||||
# Determine Run Directory: projects/{Project}/runs/run_X
|
|
||||||
project_dir = os.path.dirname(os.path.abspath(target))
|
project_dir = os.path.dirname(os.path.abspath(target))
|
||||||
runs_base = os.path.join(project_dir, "runs")
|
runs_base = os.path.join(project_dir, "runs")
|
||||||
|
|
||||||
@@ -313,12 +307,10 @@ def run_generation(target=None, specific_run_id=None, interactive=False):
|
|||||||
resume_mode = False
|
resume_mode = False
|
||||||
|
|
||||||
if specific_run_id:
|
if specific_run_id:
|
||||||
# WEB/WORKER MODE: Non-interactive, specific ID
|
|
||||||
run_dir = os.path.join(runs_base, f"run_{specific_run_id}")
|
run_dir = os.path.join(runs_base, f"run_{specific_run_id}")
|
||||||
if not os.path.exists(run_dir): os.makedirs(run_dir)
|
if not os.path.exists(run_dir): os.makedirs(run_dir)
|
||||||
resume_mode = True # Always try to resume if files exist in this specific run
|
resume_mode = True
|
||||||
else:
|
else:
|
||||||
# CLI MODE: Interactive checks
|
|
||||||
latest_run = utils.get_latest_run_folder(runs_base)
|
latest_run = utils.get_latest_run_folder(runs_base)
|
||||||
if latest_run:
|
if latest_run:
|
||||||
has_lock = False
|
has_lock = False
|
||||||
@@ -344,12 +336,10 @@ def run_generation(target=None, specific_run_id=None, interactive=False):
|
|||||||
for i, book in enumerate(data['books']):
|
for i, book in enumerate(data['books']):
|
||||||
utils.log("SERIES", f"Processing Book {book.get('book_number')}: {book.get('title')}")
|
utils.log("SERIES", f"Processing Book {book.get('book_number')}: {book.get('title')}")
|
||||||
|
|
||||||
# Check for stop signal at book level
|
|
||||||
if os.path.exists(os.path.join(run_dir, ".stop")):
|
if os.path.exists(os.path.join(run_dir, ".stop")):
|
||||||
utils.log("SYSTEM", "🛑 Stop signal detected. Aborting series generation.")
|
utils.log("SYSTEM", "Stop signal detected. Aborting series generation.")
|
||||||
break
|
break
|
||||||
|
|
||||||
# Adapter: Bible -> Blueprint
|
|
||||||
meta = data['project_metadata']
|
meta = data['project_metadata']
|
||||||
bp = {
|
bp = {
|
||||||
"book_metadata": {
|
"book_metadata": {
|
||||||
@@ -374,34 +364,26 @@ def run_generation(target=None, specific_run_id=None, interactive=False):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create Book Subfolder
|
|
||||||
safe_title = utils.sanitize_filename(book.get('title', f"Book_{i+1}"))
|
safe_title = utils.sanitize_filename(book.get('title', f"Book_{i+1}"))
|
||||||
book_folder = os.path.join(run_dir, f"Book_{book.get('book_number', i+1)}_{safe_title}")
|
book_folder = os.path.join(run_dir, f"Book_{book.get('book_number', i+1)}_{safe_title}")
|
||||||
os.makedirs(book_folder, exist_ok=True)
|
os.makedirs(book_folder, exist_ok=True)
|
||||||
|
|
||||||
# Process
|
|
||||||
process_book(bp, book_folder, context=previous_context, resume=resume_mode, interactive=interactive)
|
process_book(bp, book_folder, context=previous_context, resume=resume_mode, interactive=interactive)
|
||||||
|
|
||||||
# Update Context for next book
|
|
||||||
final_bp_path = os.path.join(book_folder, "final_blueprint.json")
|
final_bp_path = os.path.join(book_folder, "final_blueprint.json")
|
||||||
if os.path.exists(final_bp_path):
|
if os.path.exists(final_bp_path):
|
||||||
final_bp = utils.load_json(final_bp_path)
|
final_bp = utils.load_json(final_bp_path)
|
||||||
|
|
||||||
# --- Update World Bible with new characters ---
|
|
||||||
# This ensures future books know about characters invented in this book
|
|
||||||
new_chars = final_bp.get('characters', [])
|
new_chars = final_bp.get('characters', [])
|
||||||
|
|
||||||
# RELOAD BIBLE to avoid race conditions (User might have edited it in UI)
|
|
||||||
if os.path.exists(target):
|
if os.path.exists(target):
|
||||||
current_bible = utils.load_json(target)
|
current_bible = utils.load_json(target)
|
||||||
|
|
||||||
# 1. Merge New Characters
|
|
||||||
existing_names = {c['name'].lower() for c in current_bible.get('characters', [])}
|
existing_names = {c['name'].lower() for c in current_bible.get('characters', [])}
|
||||||
for char in new_chars:
|
for char in new_chars:
|
||||||
if char['name'].lower() not in existing_names:
|
if char['name'].lower() not in existing_names:
|
||||||
current_bible['characters'].append(char)
|
current_bible['characters'].append(char)
|
||||||
|
|
||||||
# 2. Sync Generated Book Metadata (Title, Beats) back to Bible
|
|
||||||
for b in current_bible.get('books', []):
|
for b in current_bible.get('books', []):
|
||||||
if b.get('book_number') == book.get('book_number'):
|
if b.get('book_number') == book.get('book_number'):
|
||||||
b['title'] = final_bp['book_metadata'].get('title', b.get('title'))
|
b['title'] = final_bp['book_metadata'].get('title', b.get('title'))
|
||||||
@@ -417,6 +399,7 @@ def run_generation(target=None, specific_run_id=None, interactive=False):
|
|||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
target_arg = sys.argv[1] if len(sys.argv) > 1 else None
|
target_arg = sys.argv[1] if len(sys.argv) > 1 else None
|
||||||
run_generation(target_arg, interactive=True)
|
run_generation(target_arg, interactive=True)
|
||||||
@@ -1,21 +1,24 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import config
|
|
||||||
import google.generativeai as genai
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.prompt import Prompt, IntPrompt, Confirm
|
from rich.prompt import Prompt, IntPrompt, Confirm
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from modules import ai, utils
|
from core import config, utils
|
||||||
from modules.web_db import db, User, Project
|
from ai import models as ai_models
|
||||||
|
from ai import setup as ai_setup
|
||||||
|
from web.db import db, User, Project
|
||||||
|
from marketing import cover as marketing_cover
|
||||||
|
from export import exporter
|
||||||
|
from cli.engine import run_generation
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
try:
|
try:
|
||||||
ai.init_models()
|
ai_setup.init_models()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
console.print(f"[bold red]❌ CRITICAL: AI Model Initialization failed.[/bold red]")
|
console.print(f"[bold red]CRITICAL: AI Model Initialization failed.[/bold red]")
|
||||||
console.print(f"[red]Error: {e}[/red]")
|
console.print(f"[red]Error: {e}[/red]")
|
||||||
Prompt.ask("Press Enter to exit...")
|
Prompt.ask("Press Enter to exit...")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -30,6 +33,7 @@ db.init_app(app)
|
|||||||
if not os.path.exists(config.PROJECTS_DIR): os.makedirs(config.PROJECTS_DIR)
|
if not os.path.exists(config.PROJECTS_DIR): os.makedirs(config.PROJECTS_DIR)
|
||||||
if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR)
|
if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR)
|
||||||
|
|
||||||
|
|
||||||
class BookWizard:
|
class BookWizard:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.project_name = "New_Project"
|
self.project_name = "New_Project"
|
||||||
@@ -43,11 +47,10 @@ class BookWizard:
|
|||||||
utils.create_default_personas()
|
utils.create_default_personas()
|
||||||
|
|
||||||
def _get_or_create_wizard_user(self):
|
def _get_or_create_wizard_user(self):
|
||||||
# Find or create a default user for CLI operations
|
|
||||||
wizard_user = User.query.filter_by(username="wizard").first()
|
wizard_user = User.query.filter_by(username="wizard").first()
|
||||||
if not wizard_user:
|
if not wizard_user:
|
||||||
console.print("[yellow]Creating default 'wizard' user for CLI operations...[/yellow]")
|
console.print("[yellow]Creating default 'wizard' user for CLI operations...[/yellow]")
|
||||||
wizard_user = User(username="wizard", password="!", is_admin=True) # Password not used for CLI
|
wizard_user = User(username="wizard", password="!", is_admin=True)
|
||||||
db.session.add(wizard_user)
|
db.session.add(wizard_user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return wizard_user
|
return wizard_user
|
||||||
@@ -57,7 +60,7 @@ class BookWizard:
|
|||||||
def ask_gemini_json(self, prompt):
|
def ask_gemini_json(self, prompt):
|
||||||
text = None
|
text = None
|
||||||
try:
|
try:
|
||||||
response = ai.model_logic.generate_content(prompt + "\nReturn ONLY valid JSON.")
|
response = ai_models.model_logic.generate_content(prompt + "\nReturn ONLY valid JSON.")
|
||||||
text = utils.clean_json(response.text)
|
text = utils.clean_json(response.text)
|
||||||
return json.loads(text)
|
return json.loads(text)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -67,7 +70,7 @@ class BookWizard:
|
|||||||
|
|
||||||
def ask_gemini_text(self, prompt):
|
def ask_gemini_text(self, prompt):
|
||||||
try:
|
try:
|
||||||
response = ai.model_logic.generate_content(prompt)
|
response = ai_models.model_logic.generate_content(prompt)
|
||||||
return response.text.strip()
|
return response.text.strip()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
console.print(f"[red]AI Error: {e}[/red]")
|
console.print(f"[red]AI Error: {e}[/red]")
|
||||||
@@ -82,7 +85,7 @@ class BookWizard:
|
|||||||
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
console.print(Panel("[bold cyan]🎭 Manage Author Personas[/bold cyan]"))
|
console.print(Panel("[bold cyan]Manage Author Personas[/bold cyan]"))
|
||||||
options = list(personas.keys())
|
options = list(personas.keys())
|
||||||
|
|
||||||
for i, name in enumerate(options):
|
for i, name in enumerate(options):
|
||||||
@@ -101,11 +104,9 @@ class BookWizard:
|
|||||||
details = {}
|
details = {}
|
||||||
|
|
||||||
if choice == len(options) + 1:
|
if choice == len(options) + 1:
|
||||||
# Create
|
|
||||||
console.print("[yellow]Define New Persona[/yellow]")
|
console.print("[yellow]Define New Persona[/yellow]")
|
||||||
selected_key = Prompt.ask("Persona Label (e.g. 'Gritty Detective')", default="New Persona")
|
selected_key = Prompt.ask("Persona Label (e.g. 'Gritty Detective')", default="New Persona")
|
||||||
else:
|
else:
|
||||||
# Edit/Delete Menu for specific persona
|
|
||||||
selected_key = options[choice-1]
|
selected_key = options[choice-1]
|
||||||
details = personas[selected_key]
|
details = personas[selected_key]
|
||||||
if isinstance(details, str): details = {"bio": details}
|
if isinstance(details, str): details = {"bio": details}
|
||||||
@@ -124,7 +125,6 @@ class BookWizard:
|
|||||||
elif sub == 3:
|
elif sub == 3:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Edit Fields
|
|
||||||
details['name'] = Prompt.ask("Author Name/Pseudonym", default=details.get('name', "AI Author"))
|
details['name'] = Prompt.ask("Author Name/Pseudonym", default=details.get('name', "AI Author"))
|
||||||
details['age'] = Prompt.ask("Age", default=details.get('age', "Unknown"))
|
details['age'] = Prompt.ask("Age", default=details.get('age', "Unknown"))
|
||||||
details['gender'] = Prompt.ask("Gender", default=details.get('gender', "Unknown"))
|
details['gender'] = Prompt.ask("Gender", default=details.get('gender', "Unknown"))
|
||||||
@@ -133,7 +133,6 @@ class BookWizard:
|
|||||||
details['language'] = Prompt.ask("Primary Language/Dialect", default=details.get('language', "Standard English"))
|
details['language'] = Prompt.ask("Primary Language/Dialect", default=details.get('language', "Standard English"))
|
||||||
details['bio'] = Prompt.ask("Writing Style/Bio", default=details.get('bio', ""))
|
details['bio'] = Prompt.ask("Writing Style/Bio", default=details.get('bio', ""))
|
||||||
|
|
||||||
# Samples
|
|
||||||
console.print("\n[bold]Style Samples[/bold]")
|
console.print("\n[bold]Style Samples[/bold]")
|
||||||
console.print(f"Place text files in the '{config.PERSONAS_DIR}' folder to reference them.")
|
console.print(f"Place text files in the '{config.PERSONAS_DIR}' folder to reference them.")
|
||||||
|
|
||||||
@@ -151,7 +150,7 @@ class BookWizard:
|
|||||||
def select_mode(self):
|
def select_mode(self):
|
||||||
while True:
|
while True:
|
||||||
self.clear()
|
self.clear()
|
||||||
console.print(Panel("[bold blue]🧙♂️ BookApp Setup Wizard[/bold blue]"))
|
console.print(Panel("[bold blue]BookApp Setup Wizard[/bold blue]"))
|
||||||
console.print("1. Create New Project")
|
console.print("1. Create New Project")
|
||||||
console.print("2. Open Existing Project")
|
console.print("2. Open Existing Project")
|
||||||
console.print("3. Manage Author Personas")
|
console.print("3. Manage Author Personas")
|
||||||
@@ -166,16 +165,14 @@ class BookWizard:
|
|||||||
self.user = self._get_or_create_wizard_user()
|
self.user = self._get_or_create_wizard_user()
|
||||||
if self.open_existing_project(): return True
|
if self.open_existing_project(): return True
|
||||||
elif choice == 3:
|
elif choice == 3:
|
||||||
# Personas don't need a user context
|
|
||||||
self.manage_personas()
|
self.manage_personas()
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def create_new_project(self):
|
def create_new_project(self):
|
||||||
self.clear()
|
self.clear()
|
||||||
console.print(Panel("[bold green]🆕 New Project Setup[/bold green]"))
|
console.print(Panel("[bold green]New Project Setup[/bold green]"))
|
||||||
|
|
||||||
# 1. Ask for Concept first to guide defaults
|
|
||||||
console.print("Tell me about your story idea (or leave empty to start from scratch).")
|
console.print("Tell me about your story idea (or leave empty to start from scratch).")
|
||||||
concept = Prompt.ask("Story Concept")
|
concept = Prompt.ask("Story Concept")
|
||||||
|
|
||||||
@@ -217,7 +214,7 @@ class BookWizard:
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
self.clear()
|
self.clear()
|
||||||
console.print(Panel("[bold green]🤖 AI Suggestions[/bold green]"))
|
console.print(Panel("[bold green]AI Suggestions[/bold green]"))
|
||||||
|
|
||||||
grid = Table.grid(padding=(0, 2))
|
grid = Table.grid(padding=(0, 2))
|
||||||
grid.add_column(style="bold cyan")
|
grid.add_column(style="bold cyan")
|
||||||
@@ -238,7 +235,6 @@ class BookWizard:
|
|||||||
grid.add_row("Length:", len_label)
|
grid.add_row("Length:", len_label)
|
||||||
grid.add_row("Est. Chapters:", str(suggestions.get('estimated_chapters', 'N/A')))
|
grid.add_row("Est. Chapters:", str(suggestions.get('estimated_chapters', 'N/A')))
|
||||||
grid.add_row("Est. Words:", str(suggestions.get('estimated_word_count', 'N/A')))
|
grid.add_row("Est. Words:", str(suggestions.get('estimated_word_count', 'N/A')))
|
||||||
|
|
||||||
grid.add_row("Tropes:", get_str('tropes'))
|
grid.add_row("Tropes:", get_str('tropes'))
|
||||||
grid.add_row("POV:", get_str('pov_style'))
|
grid.add_row("POV:", get_str('pov_style'))
|
||||||
grid.add_row("Time:", get_str('time_period'))
|
grid.add_row("Time:", get_str('time_period'))
|
||||||
@@ -272,7 +268,6 @@ class BookWizard:
|
|||||||
new_sugg = self.ask_gemini_json(refine_prompt)
|
new_sugg = self.ask_gemini_json(refine_prompt)
|
||||||
if new_sugg: suggestions = new_sugg
|
if new_sugg: suggestions = new_sugg
|
||||||
|
|
||||||
# 2. Select Type (with AI default)
|
|
||||||
default_type = "2" if suggestions.get('is_series') else "1"
|
default_type = "2" if suggestions.get('is_series') else "1"
|
||||||
|
|
||||||
console.print("1. Standalone Book")
|
console.print("1. Standalone Book")
|
||||||
@@ -288,14 +283,13 @@ class BookWizard:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def open_existing_project(self):
|
def open_existing_project(self):
|
||||||
# Query projects from the database for the wizard user
|
|
||||||
projects = Project.query.filter_by(user_id=self.user.id).order_by(Project.name).all()
|
projects = Project.query.filter_by(user_id=self.user.id).order_by(Project.name).all()
|
||||||
if not projects:
|
if not projects:
|
||||||
console.print(f"[red]No projects found for user '{self.user.username}'. Create one first.[/red]")
|
console.print(f"[red]No projects found for user '{self.user.username}'. Create one first.[/red]")
|
||||||
Prompt.ask("Press Enter to continue...")
|
Prompt.ask("Press Enter to continue...")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
console.print("\n[bold cyan]📂 Select Project[/bold cyan]")
|
console.print("\n[bold cyan]Select Project[/bold cyan]")
|
||||||
for i, p in enumerate(projects):
|
for i, p in enumerate(projects):
|
||||||
console.print(f"[{i+1}] {p.name}")
|
console.print(f"[{i+1}] {p.name}")
|
||||||
|
|
||||||
@@ -325,9 +319,8 @@ class BookWizard:
|
|||||||
|
|
||||||
def configure_details(self, suggestions=None, concept="", is_series=False):
|
def configure_details(self, suggestions=None, concept="", is_series=False):
|
||||||
if suggestions is None: suggestions = {}
|
if suggestions is None: suggestions = {}
|
||||||
console.print("\n[bold blue]📝 Project Details[/bold blue]")
|
console.print("\n[bold blue]Project Details[/bold blue]")
|
||||||
|
|
||||||
# Simplified Persona Selection (Skip creation)
|
|
||||||
personas = {}
|
personas = {}
|
||||||
if os.path.exists(config.PERSONAS_FILE):
|
if os.path.exists(config.PERSONAS_FILE):
|
||||||
try:
|
try:
|
||||||
@@ -362,10 +355,8 @@ class BookWizard:
|
|||||||
if def_len not in config.LENGTH_DEFINITIONS: def_len = "4"
|
if def_len not in config.LENGTH_DEFINITIONS: def_len = "4"
|
||||||
|
|
||||||
len_choice = Prompt.ask("Select Target Length", choices=list(config.LENGTH_DEFINITIONS.keys()), default=def_len)
|
len_choice = Prompt.ask("Select Target Length", choices=list(config.LENGTH_DEFINITIONS.keys()), default=def_len)
|
||||||
# Create a copy so we don't modify the global definition
|
|
||||||
settings = config.LENGTH_DEFINITIONS[len_choice].copy()
|
settings = config.LENGTH_DEFINITIONS[len_choice].copy()
|
||||||
|
|
||||||
# AI Defaults
|
|
||||||
def_chapters = suggestions.get('estimated_chapters', settings['chapters'])
|
def_chapters = suggestions.get('estimated_chapters', settings['chapters'])
|
||||||
def_words = suggestions.get('estimated_word_count', settings['words'])
|
def_words = suggestions.get('estimated_word_count', settings['words'])
|
||||||
def_prologue = suggestions.get('include_prologue', False)
|
def_prologue = suggestions.get('include_prologue', False)
|
||||||
@@ -376,8 +367,7 @@ class BookWizard:
|
|||||||
settings['include_prologue'] = Confirm.ask("Include Prologue?", default=def_prologue)
|
settings['include_prologue'] = Confirm.ask("Include Prologue?", default=def_prologue)
|
||||||
settings['include_epilogue'] = Confirm.ask("Include Epilogue?", default=def_epilogue)
|
settings['include_epilogue'] = Confirm.ask("Include Epilogue?", default=def_epilogue)
|
||||||
|
|
||||||
# --- GENRE STANDARD CHECK ---
|
# Genre Standard Check
|
||||||
# Parse current word count selection
|
|
||||||
w_str = str(settings.get('words', '0')).replace(',', '').replace('+', '').lower()
|
w_str = str(settings.get('words', '0')).replace(',', '').replace('+', '').lower()
|
||||||
avg_words = 0
|
avg_words = 0
|
||||||
if '-' in w_str:
|
if '-' in w_str:
|
||||||
@@ -388,7 +378,6 @@ class BookWizard:
|
|||||||
try: avg_words = int(w_str.replace('k', '000'))
|
try: avg_words = int(w_str.replace('k', '000'))
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
# Define rough standards
|
|
||||||
std_target = 0
|
std_target = 0
|
||||||
g_lower = genre.lower()
|
g_lower = genre.lower()
|
||||||
if "fantasy" in g_lower or "sci-fi" in g_lower or "space" in g_lower or "epic" in g_lower: std_target = 100000
|
if "fantasy" in g_lower or "sci-fi" in g_lower or "space" in g_lower or "epic" in g_lower: std_target = 100000
|
||||||
@@ -397,9 +386,8 @@ class BookWizard:
|
|||||||
elif "young adult" in g_lower or "ya" in g_lower: std_target = 60000
|
elif "young adult" in g_lower or "ya" in g_lower: std_target = 60000
|
||||||
|
|
||||||
if std_target > 0 and avg_words > 0:
|
if std_target > 0 and avg_words > 0:
|
||||||
# If difference is > 25%, warn user
|
|
||||||
if abs(std_target - avg_words) / std_target > 0.25:
|
if abs(std_target - avg_words) / std_target > 0.25:
|
||||||
console.print(f"\n[bold yellow]⚠️ Genre Advisory:[/bold yellow] Standard length for {genre} is approx {std_target:,} words.")
|
console.print(f"\n[bold yellow]Genre Advisory:[/bold yellow] Standard length for {genre} is approx {std_target:,} words.")
|
||||||
if Confirm.ask(f"Update target to {std_target:,} words?", default=True):
|
if Confirm.ask(f"Update target to {std_target:,} words?", default=True):
|
||||||
settings['words'] = f"{std_target:,}"
|
settings['words'] = f"{std_target:,}"
|
||||||
|
|
||||||
@@ -409,15 +397,11 @@ class BookWizard:
|
|||||||
tropes_input = Prompt.ask("Tropes/Themes (comma sep)", default=def_tropes)
|
tropes_input = Prompt.ask("Tropes/Themes (comma sep)", default=def_tropes)
|
||||||
sel_tropes = [x.strip() for x in tropes_input.split(',')] if tropes_input else []
|
sel_tropes = [x.strip() for x in tropes_input.split(',')] if tropes_input else []
|
||||||
|
|
||||||
# TITLE
|
|
||||||
# If series, this is Series Title. If book, Book Title.
|
|
||||||
title = Prompt.ask("Book Title (Leave empty for AI)", default=suggestions.get('title', ""))
|
title = Prompt.ask("Book Title (Leave empty for AI)", default=suggestions.get('title', ""))
|
||||||
|
|
||||||
# PROJECT NAME
|
|
||||||
default_proj = utils.sanitize_filename(title) if title else "New_Project"
|
default_proj = utils.sanitize_filename(title) if title else "New_Project"
|
||||||
self.project_name = Prompt.ask("Project Name (Folder)", default=default_proj)
|
self.project_name = Prompt.ask("Project Name (Folder)", default=default_proj)
|
||||||
|
|
||||||
# Create Project in DB and set path
|
|
||||||
user_dir = os.path.join(config.DATA_DIR, "users", str(self.user.id))
|
user_dir = os.path.join(config.DATA_DIR, "users", str(self.user.id))
|
||||||
if not os.path.exists(user_dir): os.makedirs(user_dir)
|
if not os.path.exists(user_dir): os.makedirs(user_dir)
|
||||||
|
|
||||||
@@ -433,12 +417,10 @@ class BookWizard:
|
|||||||
console.print("\n[italic]Note: Tone describes the overall mood or atmosphere (e.g. Dark, Whimsical, Cynical, Hopeful).[/italic]")
|
console.print("\n[italic]Note: Tone describes the overall mood or atmosphere (e.g. Dark, Whimsical, Cynical, Hopeful).[/italic]")
|
||||||
tone = Prompt.ask("Tone", default=suggestions.get('tone', "Balanced"))
|
tone = Prompt.ask("Tone", default=suggestions.get('tone', "Balanced"))
|
||||||
|
|
||||||
# POV SETTINGS
|
|
||||||
pov_style = Prompt.ask("POV Style (e.g. 'Third Person Limited', 'First Person')", default=suggestions.get('pov_style', "Third Person Limited"))
|
pov_style = Prompt.ask("POV Style (e.g. 'Third Person Limited', 'First Person')", default=suggestions.get('pov_style', "Third Person Limited"))
|
||||||
pov_chars_input = Prompt.ask("POV Characters (comma sep, leave empty if single protagonist)", default="")
|
pov_chars_input = Prompt.ask("POV Characters (comma sep, leave empty if single protagonist)", default="")
|
||||||
pov_chars = [x.strip() for x in pov_chars_input.split(',')] if pov_chars_input else []
|
pov_chars = [x.strip() for x in pov_chars_input.split(',')] if pov_chars_input else []
|
||||||
|
|
||||||
# ADVANCED STYLE
|
|
||||||
tense = Prompt.ask("Narrative Tense (e.g. 'Past', 'Present')", default=suggestions.get('narrative_tense', "Past"))
|
tense = Prompt.ask("Narrative Tense (e.g. 'Past', 'Present')", default=suggestions.get('narrative_tense', "Past"))
|
||||||
|
|
||||||
console.print("\n[bold]Content Guidelines[/bold]")
|
console.print("\n[bold]Content Guidelines[/bold]")
|
||||||
@@ -451,7 +433,6 @@ class BookWizard:
|
|||||||
console.print("\n[bold]Formatting & World Rules[/bold]")
|
console.print("\n[bold]Formatting & World Rules[/bold]")
|
||||||
time_period = Prompt.ask("Time Period/Tech (e.g. 'Modern', '1990s', 'No Cellphones')", default=suggestions.get('time_period', "Modern"))
|
time_period = Prompt.ask("Time Period/Tech (e.g. 'Modern', '1990s', 'No Cellphones')", default=suggestions.get('time_period', "Modern"))
|
||||||
|
|
||||||
# Visuals
|
|
||||||
orientation = Prompt.ask("Page Orientation", choices=["Portrait", "Landscape", "Square"], default=suggestions.get('page_orientation', "Portrait"))
|
orientation = Prompt.ask("Page Orientation", choices=["Portrait", "Landscape", "Square"], default=suggestions.get('page_orientation', "Portrait"))
|
||||||
|
|
||||||
console.print("[italic]Define formatting rules (e.g. 'Chapter Headers: POV + Title', 'Text Messages: Italic').[/italic]")
|
console.print("[italic]Define formatting rules (e.g. 'Chapter Headers: POV + Title', 'Text Messages: Italic').[/italic]")
|
||||||
@@ -459,7 +440,6 @@ class BookWizard:
|
|||||||
fmt_input = Prompt.ask("Formatting Rules (comma sep)", default=def_fmt)
|
fmt_input = Prompt.ask("Formatting Rules (comma sep)", default=def_fmt)
|
||||||
fmt_rules = [x.strip() for x in fmt_input.split(',')] if fmt_input else []
|
fmt_rules = [x.strip() for x in fmt_input.split(',')] if fmt_input else []
|
||||||
|
|
||||||
# Update book_metadata with new fields
|
|
||||||
style_data = {
|
style_data = {
|
||||||
"tone": tone, "tropes": sel_tropes,
|
"tone": tone, "tropes": sel_tropes,
|
||||||
"pov_style": pov_style, "pov_characters": pov_chars,
|
"pov_style": pov_style, "pov_characters": pov_chars,
|
||||||
@@ -481,7 +461,6 @@ class BookWizard:
|
|||||||
"style": style_data
|
"style": style_data
|
||||||
}
|
}
|
||||||
|
|
||||||
# Initialize Books List
|
|
||||||
self.data['books'] = []
|
self.data['books'] = []
|
||||||
if is_series:
|
if is_series:
|
||||||
count = IntPrompt.ask("How many books in the series?", default=3)
|
count = IntPrompt.ask("How many books in the series?", default=3)
|
||||||
@@ -501,7 +480,7 @@ class BookWizard:
|
|||||||
})
|
})
|
||||||
|
|
||||||
def enrich_blueprint(self):
|
def enrich_blueprint(self):
|
||||||
console.print("\n[bold yellow]✨ Generating full Book Bible (Characters, Plot, etc.)...[/bold yellow]")
|
console.print("\n[bold yellow]Generating full Book Bible (Characters, Plot, etc.)...[/bold yellow]")
|
||||||
|
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
ROLE: Creative Director
|
ROLE: Creative Director
|
||||||
@@ -527,11 +506,9 @@ class BookWizard:
|
|||||||
if new_data:
|
if new_data:
|
||||||
if 'characters' in new_data:
|
if 'characters' in new_data:
|
||||||
self.data['characters'] = new_data['characters']
|
self.data['characters'] = new_data['characters']
|
||||||
# Filter defaults
|
|
||||||
self.data['characters'] = [c for c in self.data['characters'] if c.get('name') and c.get('name').lower() not in ['name', 'character name', 'role', 'protagonist', 'unknown']]
|
self.data['characters'] = [c for c in self.data['characters'] if c.get('name') and c.get('name').lower() not in ['name', 'character name', 'role', 'protagonist', 'unknown']]
|
||||||
|
|
||||||
if 'books' in new_data:
|
if 'books' in new_data:
|
||||||
# Merge book data carefully
|
|
||||||
ai_books = {b.get('book_number'): b for b in new_data['books']}
|
ai_books = {b.get('book_number'): b for b in new_data['books']}
|
||||||
for i, book in enumerate(self.data['books']):
|
for i, book in enumerate(self.data['books']):
|
||||||
b_num = book.get('book_number', i+1)
|
b_num = book.get('book_number', i+1)
|
||||||
@@ -548,7 +525,6 @@ class BookWizard:
|
|||||||
length = meta.get('length_settings', {})
|
length = meta.get('length_settings', {})
|
||||||
style = meta.get('style', {})
|
style = meta.get('style', {})
|
||||||
|
|
||||||
# Metadata Grid
|
|
||||||
grid = Table.grid(padding=(0, 2))
|
grid = Table.grid(padding=(0, 2))
|
||||||
grid.add_column(style="bold cyan")
|
grid.add_column(style="bold cyan")
|
||||||
grid.add_column()
|
grid.add_column()
|
||||||
@@ -558,37 +534,25 @@ class BookWizard:
|
|||||||
grid.add_row("Genre:", meta.get('genre', 'N/A'))
|
grid.add_row("Genre:", meta.get('genre', 'N/A'))
|
||||||
grid.add_row("Audience:", meta.get('target_audience', 'N/A'))
|
grid.add_row("Audience:", meta.get('target_audience', 'N/A'))
|
||||||
|
|
||||||
# Dynamic Style Display
|
|
||||||
# Define explicit order for common fields
|
|
||||||
ordered_keys = [
|
ordered_keys = [
|
||||||
"tone", "pov_style", "pov_characters",
|
"tone", "pov_style", "pov_characters",
|
||||||
"tense", "spice", "violence", "language", "dialogue_style", "time_period", "page_orientation",
|
"tense", "spice", "violence", "language", "dialogue_style", "time_period", "page_orientation",
|
||||||
"tropes"
|
"tropes"
|
||||||
]
|
]
|
||||||
|
|
||||||
defaults = {
|
defaults = {
|
||||||
"tone": "Balanced",
|
"tone": "Balanced", "pov_style": "Third Person Limited", "tense": "Past",
|
||||||
"pov_style": "Third Person Limited",
|
"spice": "Standard", "violence": "Standard", "language": "Standard",
|
||||||
"tense": "Past",
|
"dialogue_style": "Standard", "time_period": "Modern", "page_orientation": "Portrait"
|
||||||
"spice": "Standard",
|
|
||||||
"violence": "Standard",
|
|
||||||
"language": "Standard",
|
|
||||||
"dialogue_style": "Standard",
|
|
||||||
"time_period": "Modern",
|
|
||||||
"page_orientation": "Portrait"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 1. Show ordered keys first
|
|
||||||
for k in ordered_keys:
|
for k in ordered_keys:
|
||||||
val = style.get(k)
|
val = style.get(k)
|
||||||
if val in [None, "", "N/A"]:
|
if val in [None, "", "N/A"]:
|
||||||
val = defaults.get(k, 'N/A')
|
val = defaults.get(k, 'N/A')
|
||||||
|
|
||||||
if isinstance(val, list): val = ", ".join(val)
|
if isinstance(val, list): val = ", ".join(val)
|
||||||
if isinstance(val, bool): val = "Yes" if val else "No"
|
if isinstance(val, bool): val = "Yes" if val else "No"
|
||||||
grid.add_row(f"{k.replace('_', ' ').title()}:", str(val))
|
grid.add_row(f"{k.replace('_', ' ').title()}:", str(val))
|
||||||
|
|
||||||
# 2. Show remaining keys
|
|
||||||
for k, v in style.items():
|
for k, v in style.items():
|
||||||
if k not in ordered_keys and k != 'formatting_rules':
|
if k not in ordered_keys and k != 'formatting_rules':
|
||||||
val = ", ".join(v) if isinstance(v, list) else str(v)
|
val = ", ".join(v) if isinstance(v, list) else str(v)
|
||||||
@@ -602,29 +566,25 @@ class BookWizard:
|
|||||||
grid.add_row("Length:", len_str)
|
grid.add_row("Length:", len_str)
|
||||||
grid.add_row("Series:", "Yes" if meta.get('is_series') else "No")
|
grid.add_row("Series:", "Yes" if meta.get('is_series') else "No")
|
||||||
|
|
||||||
console.print(Panel(grid, title="[bold blue]📖 Project Metadata[/bold blue]", expand=False))
|
console.print(Panel(grid, title="[bold blue]Project Metadata[/bold blue]", expand=False))
|
||||||
|
|
||||||
# Formatting Rules Table
|
|
||||||
fmt_rules = style.get('formatting_rules', [])
|
fmt_rules = style.get('formatting_rules', [])
|
||||||
if fmt_rules:
|
if fmt_rules:
|
||||||
fmt_table = Table(title="Formatting Rules", show_header=False, box=None, expand=True)
|
fmt_table = Table(title="Formatting Rules", show_header=False, box=None, expand=True)
|
||||||
for i, r in enumerate(fmt_rules):
|
for i, r in enumerate(fmt_rules):
|
||||||
fmt_table.add_row(f"[bold]{i+1}.[/bold]", str(r))
|
fmt_table.add_row(f"[bold]{i+1}.[/bold]", str(r))
|
||||||
console.print(Panel(fmt_table, title="[bold blue]🎨 Formatting[/bold blue]"))
|
console.print(Panel(fmt_table, title="[bold blue]Formatting[/bold blue]"))
|
||||||
|
|
||||||
# Characters Table
|
char_table = Table(title="Characters", show_header=True, header_style="bold magenta", expand=True)
|
||||||
char_table = Table(title="👥 Characters", show_header=True, header_style="bold magenta", expand=True)
|
|
||||||
char_table.add_column("Name", style="green")
|
char_table.add_column("Name", style="green")
|
||||||
char_table.add_column("Role")
|
char_table.add_column("Role")
|
||||||
char_table.add_column("Description")
|
char_table.add_column("Description")
|
||||||
for c in data.get('characters', []):
|
for c in data.get('characters', []):
|
||||||
# Removed truncation to show full description
|
|
||||||
char_table.add_row(c.get('name', '-'), c.get('role', '-'), c.get('description', '-'))
|
char_table.add_row(c.get('name', '-'), c.get('role', '-'), c.get('description', '-'))
|
||||||
console.print(char_table)
|
console.print(char_table)
|
||||||
|
|
||||||
# Books List
|
|
||||||
for book in data.get('books', []):
|
for book in data.get('books', []):
|
||||||
console.print(f"\n[bold cyan]📘 Book {book.get('book_number')}: {book.get('title')}[/bold cyan]")
|
console.print(f"\n[bold cyan]Book {book.get('book_number')}: {book.get('title')}[/bold cyan]")
|
||||||
console.print(f"[italic]{book.get('manual_instruction')}[/italic]")
|
console.print(f"[italic]{book.get('manual_instruction')}[/italic]")
|
||||||
|
|
||||||
beats = book.get('plot_beats', [])
|
beats = book.get('plot_beats', [])
|
||||||
@@ -637,14 +597,13 @@ class BookWizard:
|
|||||||
def refine_blueprint(self, title="Refine Blueprint"):
|
def refine_blueprint(self, title="Refine Blueprint"):
|
||||||
while True:
|
while True:
|
||||||
self.clear()
|
self.clear()
|
||||||
console.print(Panel(f"[bold blue]🔧 {title}[/bold blue]"))
|
console.print(Panel(f"[bold blue]{title}[/bold blue]"))
|
||||||
self.display_summary(self.data)
|
self.display_summary(self.data)
|
||||||
console.print("\n[dim](Full JSON loaded)[/dim]")
|
console.print("\n[dim](Full JSON loaded)[/dim]")
|
||||||
|
|
||||||
change = Prompt.ask("\n[bold green]Enter instruction to change (e.g. 'Make it darker', 'Rename Bob', 'Add a twist') or 'done'[/bold green]")
|
change = Prompt.ask("\n[bold green]Enter instruction to change (e.g. 'Make it darker', 'Rename Bob', 'Add a twist') or 'done'[/bold green]")
|
||||||
if change.lower() == 'done': break
|
if change.lower() == 'done': break
|
||||||
|
|
||||||
# Inner loop for refinement
|
|
||||||
current_data = self.data
|
current_data = self.data
|
||||||
instruction = change
|
instruction = change
|
||||||
|
|
||||||
@@ -667,7 +626,7 @@ class BookWizard:
|
|||||||
break
|
break
|
||||||
|
|
||||||
self.clear()
|
self.clear()
|
||||||
console.print(Panel("[bold blue]👀 Review AI Changes[/bold blue]"))
|
console.print(Panel("[bold blue]Review AI Changes[/bold blue]"))
|
||||||
self.display_summary(new_data)
|
self.display_summary(new_data)
|
||||||
|
|
||||||
feedback = Prompt.ask("\n[bold green]Is this good? (Type 'yes' to save, or enter feedback to refine)[/bold green]")
|
feedback = Prompt.ask("\n[bold green]Is this good? (Type 'yes' to save, or enter feedback to refine)[/bold green]")
|
||||||
@@ -689,7 +648,7 @@ class BookWizard:
|
|||||||
filename = os.path.join(self.project_path, "bible.json")
|
filename = os.path.join(self.project_path, "bible.json")
|
||||||
|
|
||||||
with open(filename, 'w') as f: json.dump(self.data, f, indent=2)
|
with open(filename, 'w') as f: json.dump(self.data, f, indent=2)
|
||||||
console.print(Panel(f"[bold green]✅ Bible saved to: {filename}[/bold green]"))
|
console.print(Panel(f"[bold green]Bible saved to: {filename}[/bold green]"))
|
||||||
return filename
|
return filename
|
||||||
|
|
||||||
def manage_runs(self):
|
def manage_runs(self):
|
||||||
@@ -721,7 +680,6 @@ class BookWizard:
|
|||||||
|
|
||||||
selected_run = runs[choice-1]
|
selected_run = runs[choice-1]
|
||||||
run_path = os.path.join(runs_dir, selected_run)
|
run_path = os.path.join(runs_dir, selected_run)
|
||||||
|
|
||||||
self.manage_specific_run(run_path)
|
self.manage_specific_run(run_path)
|
||||||
|
|
||||||
def manage_specific_run(self, run_path):
|
def manage_specific_run(self, run_path):
|
||||||
@@ -729,7 +687,6 @@ class BookWizard:
|
|||||||
self.clear()
|
self.clear()
|
||||||
console.print(Panel(f"[bold blue]Run: {os.path.basename(run_path)}[/bold blue]"))
|
console.print(Panel(f"[bold blue]Run: {os.path.basename(run_path)}[/bold blue]"))
|
||||||
|
|
||||||
# Detect sub-books (Series Run)
|
|
||||||
subdirs = sorted([d for d in os.listdir(run_path) if os.path.isdir(os.path.join(run_path, d)) and d.startswith("Book_")])
|
subdirs = sorted([d for d in os.listdir(run_path) if os.path.isdir(os.path.join(run_path, d)) and d.startswith("Book_")])
|
||||||
|
|
||||||
if subdirs:
|
if subdirs:
|
||||||
@@ -768,7 +725,6 @@ class BookWizard:
|
|||||||
choice = int(Prompt.ask("Select Action", choices=["1", "2", "3"]))
|
choice = int(Prompt.ask("Select Action", choices=["1", "2", "3"]))
|
||||||
|
|
||||||
if choice == 1:
|
if choice == 1:
|
||||||
import main
|
|
||||||
bp_path = os.path.join(folder_path, "final_blueprint.json")
|
bp_path = os.path.join(folder_path, "final_blueprint.json")
|
||||||
ms_path = os.path.join(folder_path, "manuscript.json")
|
ms_path = os.path.join(folder_path, "manuscript.json")
|
||||||
|
|
||||||
@@ -777,7 +733,6 @@ class BookWizard:
|
|||||||
with open(bp_path, 'r') as f: bp = json.load(f)
|
with open(bp_path, 'r') as f: bp = json.load(f)
|
||||||
with open(ms_path, 'r') as f: ms = json.load(f)
|
with open(ms_path, 'r') as f: ms = json.load(f)
|
||||||
|
|
||||||
# Check/Generate Tracking
|
|
||||||
events_path = os.path.join(folder_path, "tracking_events.json")
|
events_path = os.path.join(folder_path, "tracking_events.json")
|
||||||
chars_path = os.path.join(folder_path, "tracking_characters.json")
|
chars_path = os.path.join(folder_path, "tracking_characters.json")
|
||||||
tracking = {"events": [], "characters": {}}
|
tracking = {"events": [], "characters": {}}
|
||||||
@@ -785,10 +740,9 @@ class BookWizard:
|
|||||||
if os.path.exists(events_path): tracking['events'] = utils.load_json(events_path)
|
if os.path.exists(events_path): tracking['events'] = utils.load_json(events_path)
|
||||||
if os.path.exists(chars_path): tracking['characters'] = utils.load_json(chars_path)
|
if os.path.exists(chars_path): tracking['characters'] = utils.load_json(chars_path)
|
||||||
|
|
||||||
main.ai.init_models()
|
ai_setup.init_models()
|
||||||
|
|
||||||
if not tracking['events'] and not tracking['characters']:
|
if not tracking['events'] and not tracking['characters']:
|
||||||
# Fallback: Use Blueprint data
|
|
||||||
console.print("[yellow]Tracking missing. Populating from Blueprint...[/yellow]")
|
console.print("[yellow]Tracking missing. Populating from Blueprint...[/yellow]")
|
||||||
tracking['events'] = bp.get('plot_beats', [])
|
tracking['events'] = bp.get('plot_beats', [])
|
||||||
tracking['characters'] = {}
|
tracking['characters'] = {}
|
||||||
@@ -802,8 +756,8 @@ class BookWizard:
|
|||||||
with open(events_path, 'w') as f: json.dump(tracking['events'], f, indent=2)
|
with open(events_path, 'w') as f: json.dump(tracking['events'], f, indent=2)
|
||||||
with open(chars_path, 'w') as f: json.dump(tracking['characters'], f, indent=2)
|
with open(chars_path, 'w') as f: json.dump(tracking['characters'], f, indent=2)
|
||||||
|
|
||||||
main.marketing.generate_cover(bp, folder_path, tracking)
|
marketing_cover.generate_cover(bp, folder_path, tracking)
|
||||||
main.export.compile_files(bp, ms, folder_path)
|
exporter.compile_files(bp, ms, folder_path)
|
||||||
console.print("[green]Cover updated and EPUB recompiled![/green]")
|
console.print("[green]Cover updated and EPUB recompiled![/green]")
|
||||||
Prompt.ask("Press Enter...")
|
Prompt.ask("Press Enter...")
|
||||||
else:
|
else:
|
||||||
@@ -820,6 +774,7 @@ class BookWizard:
|
|||||||
else:
|
else:
|
||||||
os.system(f"open '{path}'")
|
os.system(f"open '{path}'")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
w = BookWizard()
|
w = BookWizard()
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
@@ -827,7 +782,7 @@ if __name__ == "__main__":
|
|||||||
if w.select_mode():
|
if w.select_mode():
|
||||||
while True:
|
while True:
|
||||||
w.clear()
|
w.clear()
|
||||||
console.print(Panel(f"[bold blue]📂 Project: {w.project_name}[/bold blue]"))
|
console.print(Panel(f"[bold blue]Project: {w.project_name}[/bold blue]"))
|
||||||
console.print("1. Edit Bible")
|
console.print("1. Edit Bible")
|
||||||
console.print("2. Run Book Generation")
|
console.print("2. Run Book Generation")
|
||||||
console.print("3. Manage Runs")
|
console.print("3. Manage Runs")
|
||||||
@@ -842,14 +797,10 @@ if __name__ == "__main__":
|
|||||||
elif choice == 2:
|
elif choice == 2:
|
||||||
if w.load_bible():
|
if w.load_bible():
|
||||||
bible_path = os.path.join(w.project_path, "bible.json")
|
bible_path = os.path.join(w.project_path, "bible.json")
|
||||||
import main
|
run_generation(bible_path, interactive=True)
|
||||||
main.run_generation(bible_path, interactive=True)
|
|
||||||
Prompt.ask("\nGeneration complete. Press Enter...")
|
Prompt.ask("\nGeneration complete. Press Enter...")
|
||||||
elif choice == 3:
|
elif choice == 3:
|
||||||
# Manage runs
|
|
||||||
w.manage_runs()
|
w.manage_runs()
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
else:
|
|
||||||
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
|
import os
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# Ensure .env is loaded from the script's directory (VS Code fix)
|
# __file__ is core/config.py; app root is one level up
|
||||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
BASE_DIR = os.path.dirname(_HERE)
|
||||||
|
|
||||||
|
# Ensure .env is loaded from the app root
|
||||||
|
load_dotenv(os.path.join(BASE_DIR, ".env"))
|
||||||
|
|
||||||
def get_clean_env(key, default=None):
|
def get_clean_env(key, default=None):
|
||||||
val = os.getenv(key, default)
|
val = os.getenv(key, default)
|
||||||
@@ -28,7 +32,6 @@ if FLASK_SECRET == "dev-secret-key-change-this":
|
|||||||
if not API_KEY: raise ValueError("❌ CRITICAL ERROR: GEMINI_API_KEY not found.")
|
if not API_KEY: raise ValueError("❌ CRITICAL ERROR: GEMINI_API_KEY not found.")
|
||||||
|
|
||||||
# --- DATA DIRECTORIES ---
|
# --- DATA DIRECTORIES ---
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||||
PROJECTS_DIR = os.path.join(DATA_DIR, "projects")
|
PROJECTS_DIR = os.path.join(DATA_DIR, "projects")
|
||||||
PERSONAS_DIR = os.path.join(DATA_DIR, "personas")
|
PERSONAS_DIR = os.path.join(DATA_DIR, "personas")
|
||||||
@@ -36,17 +39,14 @@ PERSONAS_FILE = os.path.join(PERSONAS_DIR, "personas.json")
|
|||||||
FONTS_DIR = os.path.join(DATA_DIR, "fonts")
|
FONTS_DIR = os.path.join(DATA_DIR, "fonts")
|
||||||
|
|
||||||
# --- ENSURE DIRECTORIES EXIST ---
|
# --- ENSURE DIRECTORIES EXIST ---
|
||||||
# Critical: Create data folders immediately to prevent DB initialization errors
|
|
||||||
for d in [DATA_DIR, PROJECTS_DIR, PERSONAS_DIR, FONTS_DIR]:
|
for d in [DATA_DIR, PROJECTS_DIR, PERSONAS_DIR, FONTS_DIR]:
|
||||||
if not os.path.exists(d): os.makedirs(d, exist_ok=True)
|
if not os.path.exists(d): os.makedirs(d, exist_ok=True)
|
||||||
|
|
||||||
# --- AUTHENTICATION ---
|
# --- AUTHENTICATION ---
|
||||||
GOOGLE_CREDS = os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
|
GOOGLE_CREDS = os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
|
||||||
if GOOGLE_CREDS:
|
if GOOGLE_CREDS:
|
||||||
# Resolve to absolute path relative to this config file if not absolute
|
|
||||||
if not os.path.isabs(GOOGLE_CREDS):
|
if not os.path.isabs(GOOGLE_CREDS):
|
||||||
base = os.path.dirname(os.path.abspath(__file__))
|
GOOGLE_CREDS = os.path.join(BASE_DIR, GOOGLE_CREDS)
|
||||||
GOOGLE_CREDS = os.path.join(base, GOOGLE_CREDS)
|
|
||||||
|
|
||||||
if os.path.exists(GOOGLE_CREDS):
|
if os.path.exists(GOOGLE_CREDS):
|
||||||
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = GOOGLE_CREDS
|
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = GOOGLE_CREDS
|
||||||
@@ -2,7 +2,7 @@ import os
|
|||||||
import json
|
import json
|
||||||
import datetime
|
import datetime
|
||||||
import time
|
import time
|
||||||
import config
|
from core import config
|
||||||
import threading
|
import threading
|
||||||
import re
|
import re
|
||||||
|
|
||||||
@@ -35,7 +35,6 @@ def update_progress(percent):
|
|||||||
|
|
||||||
def clean_json(text):
|
def clean_json(text):
|
||||||
text = text.replace("```json", "").replace("```", "").strip()
|
text = text.replace("```json", "").replace("```", "").strip()
|
||||||
# Robust extraction: find first { or [ and last } or ]
|
|
||||||
start_obj = text.find('{')
|
start_obj = text.find('{')
|
||||||
start_arr = text.find('[')
|
start_arr = text.find('[')
|
||||||
if start_obj == -1 and start_arr == -1: return text
|
if start_obj == -1 and start_arr == -1: return text
|
||||||
@@ -45,13 +44,11 @@ def clean_json(text):
|
|||||||
return text[start_arr:text.rfind(']')+1]
|
return text[start_arr:text.rfind(']')+1]
|
||||||
|
|
||||||
def sanitize_filename(name):
|
def sanitize_filename(name):
|
||||||
"""Sanitizes a string to be safe for filenames."""
|
|
||||||
if not name: return "Untitled"
|
if not name: return "Untitled"
|
||||||
safe = "".join([c for c in name if c.isalnum() or c=='_']).replace(" ", "_")
|
safe = "".join([c for c in name if c.isalnum() or c=='_']).replace(" ", "_")
|
||||||
return safe if safe else "Untitled"
|
return safe if safe else "Untitled"
|
||||||
|
|
||||||
def chapter_sort_key(ch):
|
def chapter_sort_key(ch):
|
||||||
"""Sort key for chapters handling integers, strings, Prologue, and Epilogue."""
|
|
||||||
num = ch.get('num', 0)
|
num = ch.get('num', 0)
|
||||||
if isinstance(num, int): return num
|
if isinstance(num, int): return num
|
||||||
if isinstance(num, str) and num.isdigit(): return int(num)
|
if isinstance(num, str) and num.isdigit(): return int(num)
|
||||||
@@ -61,7 +58,6 @@ def chapter_sort_key(ch):
|
|||||||
return 999
|
return 999
|
||||||
|
|
||||||
def get_sorted_book_folders(run_dir):
|
def get_sorted_book_folders(run_dir):
|
||||||
"""Returns a list of book folder names in a run directory, sorted numerically."""
|
|
||||||
if not os.path.exists(run_dir): return []
|
if not os.path.exists(run_dir): return []
|
||||||
subdirs = [d for d in os.listdir(run_dir) if os.path.isdir(os.path.join(run_dir, d)) and d.startswith("Book_")]
|
subdirs = [d for d in os.listdir(run_dir) if os.path.isdir(os.path.join(run_dir, d)) and d.startswith("Book_")]
|
||||||
def sort_key(d):
|
def sort_key(d):
|
||||||
@@ -70,9 +66,7 @@ def get_sorted_book_folders(run_dir):
|
|||||||
return 0
|
return 0
|
||||||
return sorted(subdirs, key=sort_key)
|
return sorted(subdirs, key=sort_key)
|
||||||
|
|
||||||
# --- SHARED UTILS ---
|
|
||||||
def log_banner(phase, title):
|
def log_banner(phase, title):
|
||||||
"""Log a visually distinct phase separator line."""
|
|
||||||
log(phase, f"{'─' * 18} {title} {'─' * 18}")
|
log(phase, f"{'─' * 18} {title} {'─' * 18}")
|
||||||
|
|
||||||
def log(phase, msg):
|
def log(phase, msg):
|
||||||
@@ -80,12 +74,10 @@ def log(phase, msg):
|
|||||||
line = f"[{timestamp}] {phase:<15} | {msg}"
|
line = f"[{timestamp}] {phase:<15} | {msg}"
|
||||||
print(line)
|
print(line)
|
||||||
|
|
||||||
# Write to thread-specific log file if set
|
|
||||||
if getattr(_log_context, 'log_file', None):
|
if getattr(_log_context, 'log_file', None):
|
||||||
with open(_log_context.log_file, "a", encoding="utf-8") as f:
|
with open(_log_context.log_file, "a", encoding="utf-8") as f:
|
||||||
f.write(line + "\n")
|
f.write(line + "\n")
|
||||||
|
|
||||||
# Trigger callback if set (e.g. for Database logging)
|
|
||||||
if getattr(_log_context, 'callback', None):
|
if getattr(_log_context, 'callback', None):
|
||||||
try: _log_context.callback(phase, msg)
|
try: _log_context.callback(phase, msg)
|
||||||
except: pass
|
except: pass
|
||||||
@@ -94,7 +86,6 @@ def load_json(path):
|
|||||||
return json.load(open(path, 'r')) if os.path.exists(path) else None
|
return json.load(open(path, 'r')) if os.path.exists(path) else None
|
||||||
|
|
||||||
def create_default_personas():
|
def create_default_personas():
|
||||||
# Initialize empty personas file if it doesn't exist
|
|
||||||
if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR)
|
if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR)
|
||||||
if not os.path.exists(config.PERSONAS_FILE):
|
if not os.path.exists(config.PERSONAS_FILE):
|
||||||
try:
|
try:
|
||||||
@@ -102,7 +93,6 @@ def create_default_personas():
|
|||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
def get_length_presets():
|
def get_length_presets():
|
||||||
"""Returns a dict mapping Label -> Settings for use in main.py"""
|
|
||||||
presets = {}
|
presets = {}
|
||||||
for k, v in config.LENGTH_DEFINITIONS.items():
|
for k, v in config.LENGTH_DEFINITIONS.items():
|
||||||
presets[v['label']] = v
|
presets[v['label']] = v
|
||||||
@@ -145,28 +135,19 @@ def get_latest_run_folder(base_name):
|
|||||||
return os.path.join(base_name, runs[-1])
|
return os.path.join(base_name, runs[-1])
|
||||||
|
|
||||||
def update_pricing(model_name, cost_str):
|
def update_pricing(model_name, cost_str):
|
||||||
"""Parses cost string from AI selection and updates cache."""
|
|
||||||
if not model_name or not cost_str or cost_str == 'N/A': return
|
if not model_name or not cost_str or cost_str == 'N/A': return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Look for patterns like "$0.075 Input" or "$3.50/1M"
|
|
||||||
# Default to 0.0
|
|
||||||
in_cost = 0.0
|
in_cost = 0.0
|
||||||
out_cost = 0.0
|
out_cost = 0.0
|
||||||
|
|
||||||
# Extract all float-like numbers following a $ sign
|
|
||||||
prices = re.findall(r'(?:\$|USD)\s*([0-9]+\.?[0-9]*)', cost_str, re.IGNORECASE)
|
prices = re.findall(r'(?:\$|USD)\s*([0-9]+\.?[0-9]*)', cost_str, re.IGNORECASE)
|
||||||
|
|
||||||
if len(prices) >= 2:
|
if len(prices) >= 2:
|
||||||
in_cost = float(prices[0])
|
in_cost = float(prices[0])
|
||||||
out_cost = float(prices[1])
|
out_cost = float(prices[1])
|
||||||
elif len(prices) == 1:
|
elif len(prices) == 1:
|
||||||
in_cost = float(prices[0])
|
in_cost = float(prices[0])
|
||||||
out_cost = in_cost * 3 # Rough heuristic if only one price provided
|
out_cost = in_cost * 3
|
||||||
|
|
||||||
if in_cost > 0:
|
if in_cost > 0:
|
||||||
PRICING_CACHE[model_name] = {"input": in_cost, "output": out_cost}
|
PRICING_CACHE[model_name] = {"input": in_cost, "output": out_cost}
|
||||||
# log("SYSTEM", f"Updated pricing for {model_name}: In=${in_cost} | Out=${out_cost}")
|
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -174,14 +155,12 @@ def calculate_cost(model_label, input_tokens, output_tokens, image_count=0):
|
|||||||
cost = 0.0
|
cost = 0.0
|
||||||
m = model_label.lower()
|
m = model_label.lower()
|
||||||
|
|
||||||
# Check dynamic cache first
|
|
||||||
if model_label in PRICING_CACHE:
|
if model_label in PRICING_CACHE:
|
||||||
rates = PRICING_CACHE[model_label]
|
rates = PRICING_CACHE[model_label]
|
||||||
cost = (input_tokens / 1_000_000 * rates['input']) + (output_tokens / 1_000_000 * rates['output'])
|
cost = (input_tokens / 1_000_000 * rates['input']) + (output_tokens / 1_000_000 * rates['output'])
|
||||||
elif 'imagen' in m or image_count > 0:
|
elif 'imagen' in m or image_count > 0:
|
||||||
cost = (image_count * 0.04)
|
cost = (image_count * 0.04)
|
||||||
else:
|
else:
|
||||||
# Fallbacks
|
|
||||||
if 'flash' in m:
|
if 'flash' in m:
|
||||||
cost = (input_tokens / 1_000_000 * 0.075) + (output_tokens / 1_000_000 * 0.30)
|
cost = (input_tokens / 1_000_000 * 0.075) + (output_tokens / 1_000_000 * 0.30)
|
||||||
elif 'pro' in m or 'logic' in m:
|
elif 'pro' in m or 'logic' in m:
|
||||||
@@ -203,7 +182,6 @@ def log_usage(folder, model_label, usage_metadata=None, image_count=0):
|
|||||||
output_tokens = usage_metadata.candidates_token_count
|
output_tokens = usage_metadata.candidates_token_count
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
# Calculate Cost
|
|
||||||
cost = calculate_cost(model_label, input_tokens, output_tokens, image_count)
|
cost = calculate_cost(model_label, input_tokens, output_tokens, image_count)
|
||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
@@ -227,7 +205,6 @@ def log_usage(folder, model_label, usage_metadata=None, image_count=0):
|
|||||||
|
|
||||||
data["log"].append(entry)
|
data["log"].append(entry)
|
||||||
|
|
||||||
# Recalculate totals
|
|
||||||
t_in = sum(x.get('input_tokens', 0) for x in data["log"])
|
t_in = sum(x.get('input_tokens', 0) for x in data["log"])
|
||||||
t_out = sum(x.get('output_tokens', 0) for x in data["log"])
|
t_out = sum(x.get('output_tokens', 0) for x in data["log"])
|
||||||
t_img = sum(x.get('images', 0) for x in data["log"])
|
t_img = sum(x.get('images', 0) for x in data["log"])
|
||||||
@@ -237,7 +214,6 @@ def log_usage(folder, model_label, usage_metadata=None, image_count=0):
|
|||||||
if 'cost' in x:
|
if 'cost' in x:
|
||||||
total_cost += x['cost']
|
total_cost += x['cost']
|
||||||
else:
|
else:
|
||||||
# Fallback calculation for old logs without explicit cost field
|
|
||||||
c = 0.0
|
c = 0.0
|
||||||
mx = x.get('model', '').lower()
|
mx = x.get('model', '').lower()
|
||||||
ix = x.get('input_tokens', 0)
|
ix = x.get('input_tokens', 0)
|
||||||
@@ -18,11 +18,14 @@ services:
|
|||||||
# --- DEVELOPMENT (Code Sync) ---
|
# --- DEVELOPMENT (Code Sync) ---
|
||||||
# UNCOMMENT these lines only if you are developing and want to see changes instantly.
|
# UNCOMMENT these lines only if you are developing and want to see changes instantly.
|
||||||
# For production/deployment, keep them commented out so the container uses the built image code.
|
# For production/deployment, keep them commented out so the container uses the built image code.
|
||||||
# - ./modules:/app/modules
|
# - ./core:/app/core
|
||||||
|
# - ./ai:/app/ai
|
||||||
|
# - ./story:/app/story
|
||||||
|
# - ./marketing:/app/marketing
|
||||||
|
# - ./export:/app/export
|
||||||
|
# - ./web:/app/web
|
||||||
|
# - ./cli:/app/cli
|
||||||
# - ./templates:/app/templates
|
# - ./templates:/app/templates
|
||||||
# - ./main.py:/app/main.py
|
|
||||||
# - ./wizard.py:/app/wizard.py
|
|
||||||
# - ./config.py:/app/config.py
|
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- GOOGLE_APPLICATION_CREDENTIALS=/app/credentials.json
|
- GOOGLE_APPLICATION_CREDENTIALS=/app/credentials.json
|
||||||
|
|||||||
0
export/__init__.py
Normal file
0
export/__init__.py
Normal file
@@ -2,7 +2,8 @@ import os
|
|||||||
import markdown
|
import markdown
|
||||||
from docx import Document
|
from docx import Document
|
||||||
from ebooklib import epub
|
from ebooklib import epub
|
||||||
from . import utils
|
from core import utils
|
||||||
|
|
||||||
|
|
||||||
def create_readme(folder, bp):
|
def create_readme(folder, bp):
|
||||||
meta = bp['book_metadata']
|
meta = bp['book_metadata']
|
||||||
@@ -10,6 +11,7 @@ def create_readme(folder, bp):
|
|||||||
content = f"""# {meta['title']}\n**Generated by BookApp**\n\n## Stats Used\n- **Type:** {ls.get('label', 'Custom')}\n- **Planned Chapters:** {ls['chapters']}\n- **Logic Depth:** {ls['depth']}\n- **Target Words:** {ls.get('words', 'Unknown')}"""
|
content = f"""# {meta['title']}\n**Generated by BookApp**\n\n## Stats Used\n- **Type:** {ls.get('label', 'Custom')}\n- **Planned Chapters:** {ls['chapters']}\n- **Logic Depth:** {ls['depth']}\n- **Target Words:** {ls.get('words', 'Unknown')}"""
|
||||||
with open(os.path.join(folder, "README.md"), "w") as f: f.write(content)
|
with open(os.path.join(folder, "README.md"), "w") as f: f.write(content)
|
||||||
|
|
||||||
|
|
||||||
def compile_files(bp, ms, folder):
|
def compile_files(bp, ms, folder):
|
||||||
utils.log("SYSTEM", "Compiling EPUB and DOCX...")
|
utils.log("SYSTEM", "Compiling EPUB and DOCX...")
|
||||||
meta = bp.get('book_metadata', {})
|
meta = bp.get('book_metadata', {})
|
||||||
@@ -23,17 +25,14 @@ def compile_files(bp, ms, folder):
|
|||||||
doc = Document(); doc.add_heading(title, 0)
|
doc = Document(); doc.add_heading(title, 0)
|
||||||
book = epub.EpubBook(); book.set_title(title); spine = ['nav']
|
book = epub.EpubBook(); book.set_title(title); spine = ['nav']
|
||||||
|
|
||||||
# Add Cover if exists
|
|
||||||
cover_path = os.path.join(folder, "cover.png")
|
cover_path = os.path.join(folder, "cover.png")
|
||||||
if os.path.exists(cover_path):
|
if os.path.exists(cover_path):
|
||||||
with open(cover_path, 'rb') as f:
|
with open(cover_path, 'rb') as f:
|
||||||
book.set_cover("cover.png", f.read())
|
book.set_cover("cover.png", f.read())
|
||||||
|
|
||||||
# Ensure manuscript is sorted correctly before compiling
|
|
||||||
ms.sort(key=utils.chapter_sort_key)
|
ms.sort(key=utils.chapter_sort_key)
|
||||||
|
|
||||||
for c in ms:
|
for c in ms:
|
||||||
# Determine filename/type
|
|
||||||
num_str = str(c['num']).lower()
|
num_str = str(c['num']).lower()
|
||||||
if num_str == '0' or 'prologue' in num_str:
|
if num_str == '0' or 'prologue' in num_str:
|
||||||
filename = "prologue.xhtml"
|
filename = "prologue.xhtml"
|
||||||
@@ -45,7 +44,6 @@ def compile_files(bp, ms, folder):
|
|||||||
filename = f"ch_{c['num']}.xhtml"
|
filename = f"ch_{c['num']}.xhtml"
|
||||||
default_header = f"Ch {c['num']}: {c['title']}"
|
default_header = f"Ch {c['num']}: {c['title']}"
|
||||||
|
|
||||||
# Check for AI-generated header in content
|
|
||||||
content = c['content'].strip()
|
content = c['content'].strip()
|
||||||
clean_content = content.replace("```markdown", "").replace("```", "").strip()
|
clean_content = content.replace("```markdown", "").replace("```", "").strip()
|
||||||
lines = clean_content.split('\n')
|
lines = clean_content.split('\n')
|
||||||
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 shutil
|
||||||
import textwrap
|
import textwrap
|
||||||
import subprocess
|
import subprocess
|
||||||
import requests
|
from core import utils
|
||||||
from . import utils
|
from ai import models as ai_models
|
||||||
import config
|
from marketing.fonts import download_font
|
||||||
from modules import ai
|
|
||||||
from rich.prompt import Confirm
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from PIL import Image, ImageDraw, ImageFont, ImageStat
|
from PIL import Image, ImageDraw, ImageFont, ImageStat
|
||||||
@@ -16,59 +14,6 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_PIL = False
|
HAS_PIL = False
|
||||||
|
|
||||||
def download_font(font_name):
|
|
||||||
"""Attempts to download a Google Font from GitHub."""
|
|
||||||
if not font_name: font_name = "Roboto"
|
|
||||||
if not os.path.exists(config.FONTS_DIR): os.makedirs(config.FONTS_DIR)
|
|
||||||
|
|
||||||
# Handle CSS-style lists (e.g. "Roboto, sans-serif")
|
|
||||||
if "," in font_name: font_name = font_name.split(",")[0].strip()
|
|
||||||
|
|
||||||
# Handle filenames provided by AI
|
|
||||||
if font_name.lower().endswith(('.ttf', '.otf')):
|
|
||||||
font_name = os.path.splitext(font_name)[0]
|
|
||||||
|
|
||||||
font_name = font_name.strip().strip("'").strip('"')
|
|
||||||
for suffix in ["-Regular", " Regular", " regular", "Regular", " Bold", " Italic"]:
|
|
||||||
if font_name.endswith(suffix):
|
|
||||||
font_name = font_name[:-len(suffix)]
|
|
||||||
font_name = font_name.strip()
|
|
||||||
|
|
||||||
clean_name = font_name.replace(" ", "").lower()
|
|
||||||
font_filename = f"{clean_name}.ttf"
|
|
||||||
font_path = os.path.join(config.FONTS_DIR, font_filename)
|
|
||||||
|
|
||||||
if os.path.exists(font_path) and os.path.getsize(font_path) > 1000:
|
|
||||||
utils.log("ASSETS", f"Using cached font: {font_path}")
|
|
||||||
return font_path
|
|
||||||
|
|
||||||
utils.log("ASSETS", f"Downloading font: {font_name}...")
|
|
||||||
compact_name = font_name.replace(" ", "")
|
|
||||||
title_compact = "".join(x.title() for x in font_name.split())
|
|
||||||
|
|
||||||
patterns = [
|
|
||||||
f"static/{title_compact}-Regular.ttf", f"{title_compact}-Regular.ttf",
|
|
||||||
f"{title_compact}[wght].ttf", f"{title_compact}[wdth,wght].ttf",
|
|
||||||
f"static/{compact_name}-Regular.ttf", f"{compact_name}-Regular.ttf",
|
|
||||||
f"{title_compact}-Regular.otf",
|
|
||||||
]
|
|
||||||
|
|
||||||
headers = {"User-Agent": "Mozilla/5.0 (BookApp/1.0)"}
|
|
||||||
for license_type in ["ofl", "apache", "ufl"]:
|
|
||||||
base_url = f"https://github.com/google/fonts/raw/main/{license_type}/{clean_name}"
|
|
||||||
for pattern in patterns:
|
|
||||||
try:
|
|
||||||
r = requests.get(f"{base_url}/{pattern}", headers=headers, timeout=5)
|
|
||||||
if r.status_code == 200 and len(r.content) > 1000:
|
|
||||||
with open(font_path, 'wb') as f: f.write(r.content)
|
|
||||||
utils.log("ASSETS", f"✅ Downloaded {font_name} to {font_path}")
|
|
||||||
return font_path
|
|
||||||
except Exception: continue
|
|
||||||
|
|
||||||
if clean_name != "roboto":
|
|
||||||
utils.log("ASSETS", f"⚠️ Font '{font_name}' not found. Falling back to Roboto.")
|
|
||||||
return download_font("Roboto")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def evaluate_image_quality(image_path, prompt, model, folder=None):
|
def evaluate_image_quality(image_path, prompt, model, folder=None):
|
||||||
if not HAS_PIL: return None, "PIL not installed"
|
if not HAS_PIL: return None, "PIL not installed"
|
||||||
@@ -86,53 +31,6 @@ def evaluate_image_quality(image_path, prompt, model, folder=None):
|
|||||||
return data.get('score'), data.get('reason')
|
return data.get('score'), data.get('reason')
|
||||||
except Exception as e: return None, str(e)
|
except Exception as e: return None, str(e)
|
||||||
|
|
||||||
def generate_blurb(bp, folder):
|
|
||||||
utils.log("MARKETING", "Generating blurb...")
|
|
||||||
meta = bp.get('book_metadata', {})
|
|
||||||
|
|
||||||
# Format beats as a readable list, not raw JSON
|
|
||||||
beats = bp.get('plot_beats', [])
|
|
||||||
beats_text = "\n".join(f" - {b}" for b in beats[:6]) if beats else " - (no beats provided)"
|
|
||||||
|
|
||||||
# Format protagonist for the blurb
|
|
||||||
chars = bp.get('characters', [])
|
|
||||||
protagonist = next((c for c in chars if 'protagonist' in c.get('role', '').lower()), None)
|
|
||||||
protagonist_desc = f"{protagonist['name']} — {protagonist.get('description', '')}" if protagonist else "the protagonist"
|
|
||||||
|
|
||||||
prompt = f"""
|
|
||||||
ROLE: Marketing Copywriter
|
|
||||||
TASK: Write a compelling back-cover blurb for a {meta.get('genre', 'fiction')} novel.
|
|
||||||
|
|
||||||
BOOK DETAILS:
|
|
||||||
- TITLE: {meta.get('title')}
|
|
||||||
- GENRE: {meta.get('genre')}
|
|
||||||
- AUDIENCE: {meta.get('target_audience', 'General')}
|
|
||||||
- PROTAGONIST: {protagonist_desc}
|
|
||||||
- LOGLINE: {bp.get('manual_instruction', '(none)')}
|
|
||||||
- KEY PLOT BEATS:
|
|
||||||
{beats_text}
|
|
||||||
|
|
||||||
BLURB STRUCTURE:
|
|
||||||
1. HOOK (1-2 sentences): Open with the protagonist's world and the inciting disruption. Make it urgent.
|
|
||||||
2. STAKES (2-3 sentences): Raise the central conflict. What does the protagonist stand to lose?
|
|
||||||
3. TENSION (1-2 sentences): Hint at the impossible choice or escalating danger without revealing the resolution.
|
|
||||||
4. HOOK CLOSE (1 sentence): End with a tantalising question or statement that demands the reader open the book.
|
|
||||||
|
|
||||||
RULES:
|
|
||||||
- 150-200 words total.
|
|
||||||
- DO NOT reveal the ending or resolution.
|
|
||||||
- Match the genre's marketing tone ({meta.get('genre', 'fiction')}: e.g. thriller = urgent/terse, romance = emotionally charged, fantasy = epic/wondrous, horror = dread-laden).
|
|
||||||
- Use present tense for the blurb voice.
|
|
||||||
- No "Blurb:", no title prefix, no labels — marketing copy only.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
response = ai.model_writer.generate_content(prompt)
|
|
||||||
utils.log_usage(folder, ai.model_writer.name, response.usage_metadata)
|
|
||||||
blurb = response.text
|
|
||||||
with open(os.path.join(folder, "blurb.txt"), "w") as f: f.write(blurb)
|
|
||||||
with open(os.path.join(folder, "back_cover.txt"), "w") as f: f.write(blurb)
|
|
||||||
except:
|
|
||||||
utils.log("MARKETING", "Failed to generate blurb.")
|
|
||||||
|
|
||||||
def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
||||||
if not HAS_PIL:
|
if not HAS_PIL:
|
||||||
@@ -141,7 +39,6 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
|
|
||||||
utils.log("MARKETING", "Generating cover...")
|
utils.log("MARKETING", "Generating cover...")
|
||||||
meta = bp.get('book_metadata', {})
|
meta = bp.get('book_metadata', {})
|
||||||
series = bp.get('series_metadata', {})
|
|
||||||
|
|
||||||
orientation = meta.get('style', {}).get('page_orientation', 'Portrait')
|
orientation = meta.get('style', {}).get('page_orientation', 'Portrait')
|
||||||
ar = "3:4"
|
ar = "3:4"
|
||||||
@@ -156,11 +53,9 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
if 'characters' in tracking:
|
if 'characters' in tracking:
|
||||||
visual_context += f"Character Appearances: {json.dumps(tracking['characters'])}\n"
|
visual_context += f"Character Appearances: {json.dumps(tracking['characters'])}\n"
|
||||||
|
|
||||||
# Feedback Analysis
|
|
||||||
regenerate_image = True
|
regenerate_image = True
|
||||||
design_instruction = ""
|
design_instruction = ""
|
||||||
|
|
||||||
# If existing art exists and no feedback provided, preserve it (Keep Cover feature)
|
|
||||||
if os.path.exists(os.path.join(folder, "cover_art.png")) and not feedback:
|
if os.path.exists(os.path.join(folder, "cover_art.png")) and not feedback:
|
||||||
regenerate_image = False
|
regenerate_image = False
|
||||||
|
|
||||||
@@ -179,8 +74,8 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
OUTPUT_FORMAT (JSON): {{ "action": "REGENERATE_LAYOUT" or "REGENERATE_IMAGE", "instruction": "Specific instruction for Art Director" }}
|
OUTPUT_FORMAT (JSON): {{ "action": "REGENERATE_LAYOUT" or "REGENERATE_IMAGE", "instruction": "Specific instruction for Art Director" }}
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
resp = ai.model_logic.generate_content(analysis_prompt)
|
resp = ai_models.model_logic.generate_content(analysis_prompt)
|
||||||
utils.log_usage(folder, ai.model_logic.name, resp.usage_metadata)
|
utils.log_usage(folder, ai_models.model_logic.name, resp.usage_metadata)
|
||||||
decision = json.loads(utils.clean_json(resp.text))
|
decision = json.loads(utils.clean_json(resp.text))
|
||||||
if decision.get('action') == 'REGENERATE_LAYOUT':
|
if decision.get('action') == 'REGENERATE_LAYOUT':
|
||||||
regenerate_image = False
|
regenerate_image = False
|
||||||
@@ -191,7 +86,6 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
|
|
||||||
genre = meta.get('genre', 'Fiction')
|
genre = meta.get('genre', 'Fiction')
|
||||||
tone = meta.get('style', {}).get('tone', 'Balanced')
|
tone = meta.get('style', {}).get('tone', 'Balanced')
|
||||||
# Map genre to visual style suggestion
|
|
||||||
genre_style_map = {
|
genre_style_map = {
|
||||||
'thriller': 'dark, cinematic, high-contrast photography style',
|
'thriller': 'dark, cinematic, high-contrast photography style',
|
||||||
'mystery': 'moody, atmospheric, noir-inspired painting',
|
'mystery': 'moody, atmospheric, noir-inspired painting',
|
||||||
@@ -237,19 +131,17 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
response = ai.model_artist.generate_content(design_prompt)
|
response = ai_models.model_artist.generate_content(design_prompt)
|
||||||
utils.log_usage(folder, ai.model_artist.name, response.usage_metadata)
|
utils.log_usage(folder, ai_models.model_artist.name, response.usage_metadata)
|
||||||
design = json.loads(utils.clean_json(response.text))
|
design = json.loads(utils.clean_json(response.text))
|
||||||
|
|
||||||
bg_color = design.get('primary_color', '#252570')
|
bg_color = design.get('primary_color', '#252570')
|
||||||
text_color = design.get('text_color', '#FFFFFF')
|
|
||||||
|
|
||||||
art_prompt = design.get('art_prompt', f"Cover art for {meta.get('title')}")
|
art_prompt = design.get('art_prompt', f"Cover art for {meta.get('title')}")
|
||||||
with open(os.path.join(folder, "cover_art_prompt.txt"), "w") as f:
|
with open(os.path.join(folder, "cover_art_prompt.txt"), "w") as f:
|
||||||
f.write(art_prompt)
|
f.write(art_prompt)
|
||||||
|
|
||||||
img = None
|
img = None
|
||||||
image_generated = False
|
|
||||||
width, height = 600, 900
|
width, height = 600, 900
|
||||||
|
|
||||||
best_img_score = 0
|
best_img_score = 0
|
||||||
@@ -260,23 +152,22 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
for i in range(1, MAX_IMG_ATTEMPTS + 1):
|
for i in range(1, MAX_IMG_ATTEMPTS + 1):
|
||||||
utils.log("MARKETING", f"Generating cover art (Attempt {i}/{MAX_IMG_ATTEMPTS})...")
|
utils.log("MARKETING", f"Generating cover art (Attempt {i}/{MAX_IMG_ATTEMPTS})...")
|
||||||
try:
|
try:
|
||||||
if not ai.model_image: raise ImportError("No Image Generation Model available.")
|
if not ai_models.model_image: raise ImportError("No Image Generation Model available.")
|
||||||
|
|
||||||
status = "success"
|
status = "success"
|
||||||
try:
|
try:
|
||||||
result = ai.model_image.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar)
|
result = ai_models.model_image.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
err_lower = str(e).lower()
|
err_lower = str(e).lower()
|
||||||
# Try fast imagen variant before falling back to legacy
|
if ai_models.HAS_VERTEX and ("resource" in err_lower or "quota" in err_lower):
|
||||||
if ai.HAS_VERTEX and ("resource" in err_lower or "quota" in err_lower):
|
|
||||||
try:
|
try:
|
||||||
utils.log("MARKETING", "⚠️ Imagen 3 failed. Trying Imagen 3 Fast...")
|
utils.log("MARKETING", "⚠️ Imagen 3 failed. Trying Imagen 3 Fast...")
|
||||||
fb_model = ai.VertexImageModel.from_pretrained("imagen-3.0-fast-generate-001")
|
fb_model = ai_models.VertexImageModel.from_pretrained("imagen-3.0-fast-generate-001")
|
||||||
result = fb_model.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar)
|
result = fb_model.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar)
|
||||||
status = "success_fast"
|
status = "success_fast"
|
||||||
except Exception:
|
except Exception:
|
||||||
utils.log("MARKETING", "⚠️ Imagen 3 Fast failed. Trying Imagen 2...")
|
utils.log("MARKETING", "⚠️ Imagen 3 Fast failed. Trying Imagen 2...")
|
||||||
fb_model = ai.VertexImageModel.from_pretrained("imagegeneration@006")
|
fb_model = ai_models.VertexImageModel.from_pretrained("imagegeneration@006")
|
||||||
result = fb_model.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar)
|
result = fb_model.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar)
|
||||||
status = "success_fallback"
|
status = "success_fallback"
|
||||||
else:
|
else:
|
||||||
@@ -297,7 +188,7 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
f"Score 1-10. Deduct 3 points if any text/watermarks are visible. "
|
f"Score 1-10. Deduct 3 points if any text/watermarks are visible. "
|
||||||
f"Deduct 2 if the image is blurry or has deformed anatomy."
|
f"Deduct 2 if the image is blurry or has deformed anatomy."
|
||||||
)
|
)
|
||||||
score, critique = evaluate_image_quality(attempt_path, cover_eval_criteria, ai.model_writer, folder)
|
score, critique = evaluate_image_quality(attempt_path, cover_eval_criteria, ai_models.model_writer, folder)
|
||||||
if score is None: score = 0
|
if score is None: score = 0
|
||||||
|
|
||||||
utils.log("MARKETING", f" -> Image Score: {score}/10. Critique: {critique}")
|
utils.log("MARKETING", f" -> Image Score: {score}/10. Critique: {critique}")
|
||||||
@@ -310,6 +201,7 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
else: subprocess.call(('xdg-open', attempt_path))
|
else: subprocess.call(('xdg-open', attempt_path))
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
|
from rich.prompt import Confirm
|
||||||
if Confirm.ask(f"Accept cover attempt {i} (Score: {score})?", default=True):
|
if Confirm.ask(f"Accept cover attempt {i} (Score: {score})?", default=True):
|
||||||
best_img_path = attempt_path
|
best_img_path = attempt_path
|
||||||
break
|
break
|
||||||
@@ -317,12 +209,10 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
utils.log("MARKETING", "User rejected cover. Retrying...")
|
utils.log("MARKETING", "User rejected cover. Retrying...")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Only keep as best if score meets minimum quality bar
|
|
||||||
if score >= 5 and score > best_img_score:
|
if score >= 5 and score > best_img_score:
|
||||||
best_img_score = score
|
best_img_score = score
|
||||||
best_img_path = attempt_path
|
best_img_path = attempt_path
|
||||||
elif best_img_path is None and score > 0:
|
elif best_img_path is None and score > 0:
|
||||||
# Accept even low-quality image if we have nothing else
|
|
||||||
best_img_score = score
|
best_img_score = score
|
||||||
best_img_path = attempt_path
|
best_img_path = attempt_path
|
||||||
|
|
||||||
@@ -330,7 +220,6 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
utils.log("MARKETING", " -> High quality image accepted.")
|
utils.log("MARKETING", " -> High quality image accepted.")
|
||||||
break
|
break
|
||||||
|
|
||||||
# Refine prompt based on critique keywords
|
|
||||||
prompt_additions = []
|
prompt_additions = []
|
||||||
critique_lower = critique.lower() if critique else ""
|
critique_lower = critique.lower() if critique else ""
|
||||||
if "scar" in critique_lower or "deform" in critique_lower:
|
if "scar" in critique_lower or "deform" in critique_lower:
|
||||||
@@ -351,20 +240,17 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
if best_img_path != final_art_path:
|
if best_img_path != final_art_path:
|
||||||
shutil.copy(best_img_path, final_art_path)
|
shutil.copy(best_img_path, final_art_path)
|
||||||
img = Image.open(final_art_path).resize((width, height)).convert("RGB")
|
img = Image.open(final_art_path).resize((width, height)).convert("RGB")
|
||||||
image_generated = True
|
|
||||||
else:
|
else:
|
||||||
utils.log("MARKETING", "Falling back to solid color cover.")
|
utils.log("MARKETING", "Falling back to solid color cover.")
|
||||||
img = Image.new('RGB', (width, height), color=bg_color)
|
img = Image.new('RGB', (width, height), color=bg_color)
|
||||||
utils.log_image_attempt(folder, "cover", art_prompt, "cover.png", "fallback_solid")
|
utils.log_image_attempt(folder, "cover", art_prompt, "cover.png", "fallback_solid")
|
||||||
else:
|
else:
|
||||||
# Load existing art
|
|
||||||
final_art_path = os.path.join(folder, "cover_art.png")
|
final_art_path = os.path.join(folder, "cover_art.png")
|
||||||
if os.path.exists(final_art_path):
|
if os.path.exists(final_art_path):
|
||||||
utils.log("MARKETING", "Using existing cover art (Layout update only).")
|
utils.log("MARKETING", "Using existing cover art (Layout update only).")
|
||||||
img = Image.open(final_art_path).resize((width, height)).convert("RGB")
|
img = Image.open(final_art_path).resize((width, height)).convert("RGB")
|
||||||
else:
|
else:
|
||||||
utils.log("MARKETING", "Existing art not found. Forcing regeneration.")
|
utils.log("MARKETING", "Existing art not found. Forcing regeneration.")
|
||||||
# Fallback to solid color if we were supposed to reuse but couldn't find it
|
|
||||||
img = Image.new('RGB', (width, height), color=bg_color)
|
img = Image.new('RGB', (width, height), color=bg_color)
|
||||||
|
|
||||||
font_path = download_font(design.get('font_name') or 'Arial')
|
font_path = download_font(design.get('font_name') or 'Arial')
|
||||||
@@ -398,8 +284,8 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
for attempt in range(1, 6):
|
for attempt in range(1, 6):
|
||||||
utils.log("MARKETING", f"Designing text layout (Attempt {attempt}/5)...")
|
utils.log("MARKETING", f"Designing text layout (Attempt {attempt}/5)...")
|
||||||
try:
|
try:
|
||||||
response = ai.model_writer.generate_content([layout_prompt, img])
|
response = ai_models.model_writer.generate_content([layout_prompt, img])
|
||||||
utils.log_usage(folder, ai.model_writer.name, response.usage_metadata)
|
utils.log_usage(folder, ai_models.model_writer.name, response.usage_metadata)
|
||||||
layout = json.loads(utils.clean_json(response.text))
|
layout = json.loads(utils.clean_json(response.text))
|
||||||
if isinstance(layout, list): layout = layout[0] if layout else {}
|
if isinstance(layout, list): layout = layout[0] if layout else {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -438,11 +324,11 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
total_h = sum(line_heights)
|
total_h = sum(line_heights)
|
||||||
current_y = y - (total_h // 2)
|
current_y = y - (total_h // 2)
|
||||||
|
|
||||||
for i, line in enumerate(lines):
|
for idx, line in enumerate(lines):
|
||||||
bbox = draw.textbbox((0, 0), line, font=font)
|
bbox = draw.textbbox((0, 0), line, font=font)
|
||||||
lx = x - ((bbox[2] - bbox[0]) / 2)
|
lx = x - ((bbox[2] - bbox[0]) / 2)
|
||||||
draw.text((lx, current_y), line, font=font, fill=color)
|
draw.text((lx, current_y), line, font=font, fill=color)
|
||||||
current_y += line_heights[i]
|
current_y += line_heights[idx]
|
||||||
|
|
||||||
draw_element('title', meta.get('title'))
|
draw_element('title', meta.get('title'))
|
||||||
draw_element('author', meta.get('author'))
|
draw_element('author', meta.get('author'))
|
||||||
@@ -450,7 +336,6 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
attempt_path = os.path.join(folder, f"cover_layout_attempt_{attempt}.png")
|
attempt_path = os.path.join(folder, f"cover_layout_attempt_{attempt}.png")
|
||||||
img_copy.save(attempt_path)
|
img_copy.save(attempt_path)
|
||||||
|
|
||||||
# Evaluate Layout
|
|
||||||
eval_prompt = f"""
|
eval_prompt = f"""
|
||||||
Analyze the text layout for the book title '{meta.get('title')}'.
|
Analyze the text layout for the book title '{meta.get('title')}'.
|
||||||
CHECKLIST:
|
CHECKLIST:
|
||||||
@@ -458,7 +343,7 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
2. Is the contrast sufficient?
|
2. Is the contrast sufficient?
|
||||||
3. Does it look professional?
|
3. Does it look professional?
|
||||||
"""
|
"""
|
||||||
score, critique = evaluate_image_quality(attempt_path, eval_prompt, ai.model_writer, folder)
|
score, critique = evaluate_image_quality(attempt_path, eval_prompt, ai_models.model_writer, folder)
|
||||||
if score is None: score = 0
|
if score is None: score = 0
|
||||||
|
|
||||||
utils.log("MARKETING", f" -> Layout Score: {score}/10. Critique: {critique}")
|
utils.log("MARKETING", f" -> Layout Score: {score}/10. Critique: {critique}")
|
||||||
@@ -478,7 +363,3 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
utils.log("MARKETING", f"Cover generation failed: {e}")
|
utils.log("MARKETING", f"Cover generation failed: {e}")
|
||||||
|
|
||||||
def create_marketing_assets(bp, folder, tracking=None, interactive=False):
|
|
||||||
generate_blurb(bp, folder)
|
|
||||||
generate_cover(bp, folder, tracking, interactive=interactive)
|
|
||||||
55
marketing/fonts.py
Normal file
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>
|
<p class="text-muted">System management and user administration.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 text-end">
|
<div class="col-md-4 text-end">
|
||||||
<a href="{{ url_for('admin_spend_report') }}" class="btn btn-outline-primary me-2"><i class="fas fa-chart-line me-2"></i>Spend Report</a>
|
<a href="{{ url_for('admin.admin_spend_report') }}" class="btn btn-outline-primary me-2"><i class="fas fa-chart-line me-2"></i>Spend Report</a>
|
||||||
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary">Back to Dashboard</a>
|
<a href="{{ url_for('project.index') }}" class="btn btn-outline-secondary">Back to Dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if u.id != current_user.id %}
|
{% if u.id != current_user.id %}
|
||||||
<form action="/admin/user/{{ u.id }}/delete" method="POST" onsubmit="return confirm('Delete user {{ u.username }} and ALL their projects? This cannot be undone.');">
|
<form action="/admin/user/{{ u.id }}/delete" method="POST" onsubmit="return confirm('Delete user {{ u.username }} and ALL their projects? This cannot be undone.');">
|
||||||
<a href="{{ url_for('impersonate_user', user_id=u.id) }}" class="btn btn-sm btn-outline-dark me-1" title="Impersonate User">
|
<a href="{{ url_for('admin.impersonate_user', user_id=u.id) }}" class="btn btn-sm btn-outline-dark me-1" title="Impersonate User">
|
||||||
<i class="fas fa-user-secret"></i>
|
<i class="fas fa-user-secret"></i>
|
||||||
</a>
|
</a>
|
||||||
<button class="btn btn-sm btn-outline-danger" title="Delete User"><i class="fas fa-trash"></i></button>
|
<button class="btn btn-sm btn-outline-danger" title="Delete User"><i class="fas fa-trash"></i></button>
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="text-muted small">Manage global AI writing rules and banned words.</p>
|
<p class="text-muted small">Manage global AI writing rules and banned words.</p>
|
||||||
<a href="{{ url_for('admin_style_guidelines') }}" class="btn btn-outline-primary w-100"><i class="fas fa-spell-check me-2"></i>Edit Style Guidelines</a>
|
<a href="{{ url_for('admin.admin_style_guidelines') }}" class="btn btn-outline-primary w-100"><i class="fas fa-spell-check me-2"></i>Edit Style Guidelines</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<p class="text-muted">Aggregate cost analysis per user.</p>
|
<p class="text-muted">Aggregate cost analysis per user.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 text-end">
|
<div class="col-md-4 text-end">
|
||||||
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-secondary">Back to Admin</a>
|
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-outline-secondary">Back to Admin</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2><i class="fas fa-spell-check me-2 text-primary"></i>Style Guidelines</h2>
|
<h2><i class="fas fa-spell-check me-2 text-primary"></i>Style Guidelines</h2>
|
||||||
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-secondary">Back to Admin</a>
|
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-outline-secondary">Back to Admin</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
<button type="submit" class="btn btn-primary btn-lg">
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
<i class="fas fa-save me-2"></i>Save Guidelines
|
<i class="fas fa-save me-2"></i>Save Guidelines
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" formaction="{{ url_for('optimize_models') }}" class="btn btn-outline-info w-100 mt-2">
|
<button type="submit" formaction="{{ url_for('admin.optimize_models') }}" class="btn btn-outline-info w-100 mt-2">
|
||||||
<i class="fas fa-magic me-2"></i>Auto-Refresh with AI
|
<i class="fas fa-magic me-2"></i>Auto-Refresh with AI
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
{% if session.get('original_admin_id') %}
|
{% if session.get('original_admin_id') %}
|
||||||
<div class="bg-danger text-white text-center py-2 shadow-sm" style="position: sticky; top: 0; z-index: 1050;">
|
<div class="bg-danger text-white text-center py-2 shadow-sm" style="position: sticky; top: 0; z-index: 1050;">
|
||||||
<strong><i class="fas fa-user-secret me-2"></i>Viewing site as {{ current_user.username }}</strong>
|
<strong><i class="fas fa-user-secret me-2"></i>Viewing site as {{ current_user.username }}</strong>
|
||||||
<a href="{{ url_for('stop_impersonate') }}" class="btn btn-sm btn-light ms-3 text-danger fw-bold">Stop Impersonating</a>
|
<a href="{{ url_for('admin.stop_impersonate') }}" class="btn btn-sm btn-light ms-3 text-danger fw-bold">Stop Impersonating</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2><i class="fas fa-search me-2"></i>Consistency Report</h2>
|
<h2><i class="fas fa-search me-2"></i>Consistency Report</h2>
|
||||||
<a href="{{ url_for('view_run', id=run.id) }}" class="btn btn-outline-secondary">Back to Run</a>
|
<a href="{{ url_for('run.view_run', id=run.id) }}" class="btn btn-outline-secondary">Back to Run</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card shadow-sm mb-4">
|
<div class="card shadow-sm mb-4">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<h4 class="mb-0">{% if name %}Edit Persona: {{ name }}{% else %}New Persona{% endif %}</h4>
|
<h4 class="mb-0">{% if name %}Edit Persona: {{ name }}{% else %}New Persona{% endif %}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form action="{{ url_for('save_persona') }}" method="POST">
|
<form action="{{ url_for('persona.save_persona') }}" method="POST">
|
||||||
<input type="hidden" name="old_name" value="{{ name }}">
|
<input type="hidden" name="old_name" value="{{ name }}">
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<a href="{{ url_for('list_personas') }}" class="btn btn-outline-secondary">Cancel</a>
|
<a href="{{ url_for('persona.list_personas') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
<button type="submit" class="btn btn-primary">Save Persona</button>
|
<button type="submit" class="btn btn-primary">Save Persona</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2><i class="fas fa-users me-2"></i>Author Personas</h2>
|
<h2><i class="fas fa-users me-2"></i>Author Personas</h2>
|
||||||
<a href="{{ url_for('new_persona') }}" class="btn btn-primary"><i class="fas fa-plus me-2"></i>Create New Persona</a>
|
<a href="{{ url_for('persona.new_persona') }}" class="btn btn-primary"><i class="fas fa-plus me-2"></i>Create New Persona</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -16,8 +16,8 @@
|
|||||||
<p class="card-text small">{{ p.bio[:150] }}...</p>
|
<p class="card-text small">{{ p.bio[:150] }}...</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer bg-white border-top-0 d-flex justify-content-between">
|
<div class="card-footer bg-white border-top-0 d-flex justify-content-between">
|
||||||
<a href="{{ url_for('edit_persona', name=name) }}" class="btn btn-sm btn-outline-primary">Edit</a>
|
<a href="{{ url_for('persona.edit_persona', name=name) }}" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||||
<form action="{{ url_for('delete_persona', name=name) }}" method="POST" onsubmit="return confirm('Delete this persona?');">
|
<form action="{{ url_for('persona.delete_persona', name=name) }}" method="POST" onsubmit="return confirm('Delete this persona?');">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -164,7 +164,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>${{ "%.4f"|format(r.cost) }}</td>
|
<td>${{ "%.4f"|format(r.cost) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for('view_run', id=r.id) }}" class="btn btn-sm btn-outline-primary">
|
<a href="{{ url_for('run.view_run', id=r.id) }}" class="btn btn-sm btn-outline-primary">
|
||||||
{{ 'View Active' if active_run and r.id == active_run.id and active_run.status in ['running', 'queued'] else 'View' }}
|
{{ 'View Active' if active_run and r.id == active_run.id and active_run.status in ['running', 'queued'] else 'View' }}
|
||||||
</a>
|
</a>
|
||||||
{% if r.status in ['failed', 'cancelled', 'interrupted'] %}
|
{% if r.status in ['failed', 'cancelled', 'interrupted'] %}
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
<small class="text-muted">Run #{{ run.id }}</small>
|
<small class="text-muted">Run #{{ run.id }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<form action="{{ url_for('sync_book_metadata', run_id=run.id, book_folder=book_folder) }}" method="POST" class="d-inline me-2" onsubmit="return confirm('This will re-scan your manuscript to update the character list and author persona. Continue?');">
|
<form action="{{ url_for('run.sync_book_metadata', run_id=run.id, book_folder=book_folder) }}" method="POST" class="d-inline me-2" onsubmit="return confirm('This will re-scan your manuscript to update the character list and author persona. Continue?');">
|
||||||
<button type="submit" class="btn btn-outline-info" data-bs-toggle="tooltip" title="Scans your manual edits to update the character database and author writing style. Use this after making significant edits.">
|
<button type="submit" class="btn btn-outline-info" data-bs-toggle="tooltip" title="Scans your manual edits to update the character database and author writing style. Use this after making significant edits.">
|
||||||
<i class="fas fa-sync me-2"></i>Sync Metadata
|
<i class="fas fa-sync me-2"></i>Sync Metadata
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<a href="{{ url_for('view_run', id=run.id) }}" class="btn btn-outline-secondary">Back to Run</a>
|
<a href="{{ url_for('run.view_run', id=run.id) }}" class="btn btn-outline-secondary">Back to Run</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2><i class="fas fa-book me-2"></i>Run #{{ run.id }}</h2>
|
<h2><i class="fas fa-book me-2"></i>Run #{{ run.id }}</h2>
|
||||||
<p class="text-muted mb-0">Project: <a href="{{ url_for('view_project', id=run.project_id) }}">{{ run.project.name }}</a></p>
|
<p class="text-muted mb-0">Project: <a href="{{ url_for('project.view_project', id=run.project_id) }}">{{ run.project.name }}</a></p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-outline-primary me-2" type="button" data-bs-toggle="collapse" data-bs-target="#bibleCollapse" aria-expanded="false" aria-controls="bibleCollapse">
|
<button class="btn btn-outline-primary me-2" type="button" data-bs-toggle="collapse" data-bs-target="#bibleCollapse" aria-expanded="false" aria-controls="bibleCollapse">
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<button class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#modifyRunModal" data-bs-toggle="tooltip" title="Create a new run based on this one, but with different instructions (e.g. 'Make it darker').">
|
<button class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#modifyRunModal" data-bs-toggle="tooltip" title="Create a new run based on this one, but with different instructions (e.g. 'Make it darker').">
|
||||||
<i class="fas fa-pen-fancy me-2"></i>Modify & Re-run
|
<i class="fas fa-pen-fancy me-2"></i>Modify & Re-run
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ url_for('view_project', id=run.project_id) }}" class="btn btn-outline-secondary">Back to Project</a>
|
<a href="{{ url_for('project.view_project', id=run.project_id) }}" class="btn btn-outline-secondary">Back to Project</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
<div class="col-md-4 mb-3">
|
<div class="col-md-4 mb-3">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
{% if book.cover %}
|
{% if book.cover %}
|
||||||
<img src="{{ url_for('download_artifact', run_id=run.id, file=book.cover) }}" class="img-fluid rounded shadow-sm mb-3" alt="Book Cover" style="max-height: 400px;">
|
<img src="{{ url_for('run.download_artifact', run_id=run.id, file=book.cover) }}" class="img-fluid rounded shadow-sm mb-3" alt="Book Cover" style="max-height: 400px;">
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="alert alert-secondary py-5">
|
<div class="alert alert-secondary py-5">
|
||||||
<i class="fas fa-image fa-3x mb-3"></i><br>No cover.
|
<i class="fas fa-image fa-3x mb-3"></i><br>No cover.
|
||||||
@@ -132,7 +132,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if loop.first %}
|
{% if loop.first %}
|
||||||
<form action="{{ url_for('regenerate_artifacts', run_id=run.id) }}" method="POST" class="mt-2">
|
<form action="{{ url_for('run.regenerate_artifacts', run_id=run.id) }}" method="POST" class="mt-2">
|
||||||
<textarea name="feedback" class="form-control mb-2 form-control-sm" rows="1" placeholder="Cover Feedback..."></textarea>
|
<textarea name="feedback" class="form-control mb-2 form-control-sm" rows="1" placeholder="Cover Feedback..."></textarea>
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary w-100">
|
<button type="submit" class="btn btn-sm btn-outline-primary w-100">
|
||||||
<i class="fas fa-sync me-2"></i>Regenerate All
|
<i class="fas fa-sync me-2"></i>Regenerate All
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
<h6 class="fw-bold">Artifacts</h6>
|
<h6 class="fw-bold">Artifacts</h6>
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<div class="d-flex flex-wrap gap-2">
|
||||||
{% for art in book.artifacts %}
|
{% for art in book.artifacts %}
|
||||||
<a href="{{ url_for('download_artifact', run_id=run.id, file=art.path) }}" class="btn btn-sm btn-outline-success">
|
<a href="{{ url_for('run.download_artifact', run_id=run.id, file=art.path) }}" class="btn btn-sm btn-outline-success">
|
||||||
<i class="fas fa-download me-1"></i> {{ art.name }}
|
<i class="fas fa-download me-1"></i> {{ art.name }}
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -164,10 +164,10 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<a href="{{ url_for('read_book', run_id=run.id, book_folder=book.folder) }}" class="btn btn-primary">
|
<a href="{{ url_for('run.read_book', run_id=run.id, book_folder=book.folder) }}" class="btn btn-primary">
|
||||||
<i class="fas fa-book-reader me-2"></i>Read & Edit
|
<i class="fas fa-book-reader me-2"></i>Read & Edit
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('check_consistency', run_id=run.id, book_folder=book.folder) }}" class="btn btn-outline-warning ms-2">
|
<a href="{{ url_for('run.check_consistency', run_id=run.id, book_folder=book.folder) }}" class="btn btn-outline-warning ms-2">
|
||||||
<i class="fas fa-search me-2"></i>Check Consistency
|
<i class="fas fa-search me-2"></i>Check Consistency
|
||||||
</a>
|
</a>
|
||||||
<button class="btn btn-warning ms-2" data-bs-toggle="modal" data-bs-target="#reviseBookModal{{ loop.index }}" title="Regenerate this book with changes, keeping others.">
|
<button class="btn btn-warning ms-2" data-bs-toggle="modal" data-bs-target="#reviseBookModal{{ loop.index }}" title="Regenerate this book with changes, keeping others.">
|
||||||
@@ -183,7 +183,7 @@
|
|||||||
<!-- Revise Book Modal -->
|
<!-- Revise Book Modal -->
|
||||||
<div class="modal fade" id="reviseBookModal{{ loop.index }}" tabindex="-1">
|
<div class="modal fade" id="reviseBookModal{{ loop.index }}" tabindex="-1">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<form class="modal-content" action="{{ url_for('revise_book', run_id=run.id, book_folder=book.folder) }}" method="POST">
|
<form class="modal-content" action="{{ url_for('project.revise_book', run_id=run.id, book_folder=book.folder) }}" method="POST">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title">Revise Book</h5>
|
<h5 class="modal-title">Revise Book</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
<p class="text-muted">AI Model Health, Selection Reasoning, and Availability.</p>
|
<p class="text-muted">AI Model Health, Selection Reasoning, and Availability.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 text-end">
|
<div class="col-md-4 text-end">
|
||||||
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary me-2">Back to Dashboard</a>
|
<a href="{{ url_for('project.index') }}" class="btn btn-outline-secondary me-2">Back to Dashboard</a>
|
||||||
<form action="{{ url_for('optimize_models') }}" method="POST" class="d-inline" onsubmit="return confirm('This will re-analyze all available models. Continue?');">
|
<form action="{{ url_for('admin.optimize_models') }}" method="POST" class="d-inline" onsubmit="return confirm('This will re-analyze all available models. Continue?');">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="fas fa-sync me-2"></i>Refresh & Optimize
|
<i class="fas fa-sync me-2"></i>Refresh & Optimize
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
0
web/__init__.py
Normal file
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,14 +4,16 @@ from datetime import datetime
|
|||||||
|
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
|
||||||
class User(UserMixin, db.Model):
|
class User(UserMixin, db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
username = db.Column(db.String(150), unique=True, nullable=False)
|
username = db.Column(db.String(150), unique=True, nullable=False)
|
||||||
password = db.Column(db.String(150), nullable=False)
|
password = db.Column(db.String(150), nullable=False)
|
||||||
api_key = db.Column(db.String(200), nullable=True) # Optional: User-specific Gemini Key
|
api_key = db.Column(db.String(200), nullable=True)
|
||||||
total_spend = db.Column(db.Float, default=0.0)
|
total_spend = db.Column(db.Float, default=0.0)
|
||||||
is_admin = db.Column(db.Boolean, default=False)
|
is_admin = db.Column(db.Boolean, default=False)
|
||||||
|
|
||||||
|
|
||||||
class Project(db.Model):
|
class Project(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
@@ -19,20 +21,19 @@ class Project(db.Model):
|
|||||||
folder_path = db.Column(db.String(300), nullable=False)
|
folder_path = db.Column(db.String(300), nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
# Relationships
|
|
||||||
runs = db.relationship('Run', backref='project', lazy=True, cascade="all, delete-orphan")
|
runs = db.relationship('Run', backref='project', lazy=True, cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
class Run(db.Model):
|
class Run(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False)
|
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False)
|
||||||
status = db.Column(db.String(50), default="queued") # queued, running, completed, failed
|
status = db.Column(db.String(50), default="queued")
|
||||||
start_time = db.Column(db.DateTime, default=datetime.utcnow)
|
start_time = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
end_time = db.Column(db.DateTime, nullable=True)
|
end_time = db.Column(db.DateTime, nullable=True)
|
||||||
log_file = db.Column(db.String(300), nullable=True)
|
log_file = db.Column(db.String(300), nullable=True)
|
||||||
cost = db.Column(db.Float, default=0.0)
|
cost = db.Column(db.Float, default=0.0)
|
||||||
progress = db.Column(db.Integer, default=0)
|
progress = db.Column(db.Integer, default=0)
|
||||||
|
|
||||||
# Relationships
|
|
||||||
logs = db.relationship('LogEntry', backref='run', lazy=True, cascade="all, delete-orphan")
|
logs = db.relationship('LogEntry', backref='run', lazy=True, cascade="all, delete-orphan")
|
||||||
|
|
||||||
def duration(self):
|
def duration(self):
|
||||||
@@ -40,6 +41,7 @@ class Run(db.Model):
|
|||||||
return str(self.end_time - self.start_time).split('.')[0]
|
return str(self.end_time - self.start_time).split('.')[0]
|
||||||
return "Running..."
|
return "Running..."
|
||||||
|
|
||||||
|
|
||||||
class LogEntry(db.Model):
|
class LogEntry(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
run_id = db.Column(db.Integer, db.ForeignKey('run.id'), nullable=False)
|
run_id = db.Column(db.Integer, db.ForeignKey('run.id'), nullable=False)
|
||||||
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
|
import shutil
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from huey import SqliteHuey
|
from huey import SqliteHuey
|
||||||
from .web_db import db, Run, User, Project
|
from web.db import db, Run, User, Project
|
||||||
from . import utils
|
from core import utils, config
|
||||||
import config
|
from ai import models as ai_models
|
||||||
from . import story, ai, marketing, export
|
from ai import setup as ai_setup
|
||||||
|
from story import bible_tracker
|
||||||
|
from marketing import cover as marketing_cover
|
||||||
|
from export import exporter
|
||||||
|
|
||||||
# Configure Huey (Task Queue)
|
# Configure Huey (Task Queue)
|
||||||
huey = SqliteHuey('bookapp_queue', filename=os.path.join(config.DATA_DIR, 'queue.db'))
|
huey = SqliteHuey('bookapp_queue', filename=os.path.join(config.DATA_DIR, 'queue.db'))
|
||||||
@@ -43,7 +46,7 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
|
|||||||
# 1. Setup Logging
|
# 1. Setup Logging
|
||||||
log_filename = f"system_log_{run_id}.txt"
|
log_filename = f"system_log_{run_id}.txt"
|
||||||
|
|
||||||
# Log to project root initially until run folder is created by main
|
# Log to project root initially until run folder is created by engine
|
||||||
initial_log = os.path.join(project_path, log_filename)
|
initial_log = os.path.join(project_path, log_filename)
|
||||||
utils.set_log_file(initial_log)
|
utils.set_log_file(initial_log)
|
||||||
|
|
||||||
@@ -65,34 +68,28 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
|
|||||||
if feedback and source_run_id:
|
if feedback and source_run_id:
|
||||||
utils.log("SYSTEM", f"Applying feedback to Run #{source_run_id}: '{feedback}'")
|
utils.log("SYSTEM", f"Applying feedback to Run #{source_run_id}: '{feedback}'")
|
||||||
|
|
||||||
# Load Source Data (Prefer final_blueprint from source run to capture its state)
|
|
||||||
source_run_dir = os.path.join(project_path, "runs", f"run_{source_run_id}")
|
|
||||||
bible_data = utils.load_json(bible_path)
|
bible_data = utils.load_json(bible_path)
|
||||||
|
|
||||||
# Try to find the blueprint of the book in the source run
|
|
||||||
# (Simplification: If multiple books, we apply feedback to the Bible generally)
|
|
||||||
if bible_data:
|
if bible_data:
|
||||||
try:
|
try:
|
||||||
ai.init_models()
|
ai_setup.init_models()
|
||||||
new_bible = story.refine_bible(bible_data, feedback, project_path)
|
new_bible = bible_tracker.refine_bible(bible_data, feedback, project_path)
|
||||||
if new_bible:
|
if new_bible:
|
||||||
bible_data = new_bible
|
bible_data = new_bible
|
||||||
# Save updated Bible (This updates the project state to the new "fork")
|
|
||||||
with open(bible_path, 'w') as f: json.dump(bible_data, f, indent=2)
|
with open(bible_path, 'w') as f: json.dump(bible_data, f, indent=2)
|
||||||
utils.log("SYSTEM", "Bible updated with feedback.")
|
utils.log("SYSTEM", "Bible updated with feedback.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
utils.log("ERROR", f"Failed to refine bible: {e}")
|
utils.log("ERROR", f"Failed to refine bible: {e}")
|
||||||
|
|
||||||
# 1.2 Keep Cover Art Logic
|
# 1.2 Keep Cover Art Logic
|
||||||
if keep_cover and os.path.exists(source_run_dir):
|
if keep_cover:
|
||||||
|
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...")
|
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}")
|
current_run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
|
||||||
if not os.path.exists(current_run_dir): os.makedirs(current_run_dir)
|
if not os.path.exists(current_run_dir): os.makedirs(current_run_dir)
|
||||||
|
|
||||||
# Map Source Books -> Target Books by Book Number
|
|
||||||
source_books = {}
|
source_books = {}
|
||||||
for d in os.listdir(source_run_dir):
|
for d in os.listdir(source_run_dir):
|
||||||
if d.startswith("Book_") and os.path.isdir(os.path.join(source_run_dir, d)):
|
if d.startswith("Book_") and os.path.isdir(os.path.join(source_run_dir, d)):
|
||||||
@@ -104,20 +101,15 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
|
|||||||
for i, book in enumerate(bible_data['books']):
|
for i, book in enumerate(bible_data['books']):
|
||||||
b_num = book.get('book_number', i+1)
|
b_num = book.get('book_number', i+1)
|
||||||
if b_num in source_books:
|
if b_num in source_books:
|
||||||
# Found matching book in source
|
|
||||||
src_folder = source_books[b_num]
|
src_folder = source_books[b_num]
|
||||||
|
|
||||||
# Predict Target Folder
|
|
||||||
safe_title = utils.sanitize_filename(book.get('title', f"Book_{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}")
|
target_folder = os.path.join(current_run_dir, f"Book_{b_num}_{safe_title}")
|
||||||
|
|
||||||
os.makedirs(target_folder, exist_ok=True)
|
os.makedirs(target_folder, exist_ok=True)
|
||||||
|
|
||||||
# Copy Cover
|
|
||||||
src_cover = os.path.join(src_folder, "cover.png")
|
src_cover = os.path.join(src_folder, "cover.png")
|
||||||
if os.path.exists(src_cover):
|
if os.path.exists(src_cover):
|
||||||
shutil.copy2(src_cover, os.path.join(target_folder, "cover.png"))
|
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")):
|
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"))
|
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}")
|
utils.log("SYSTEM", f" -> Copied cover for Book {b_num}")
|
||||||
@@ -131,11 +123,8 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
|
|||||||
|
|
||||||
runs_dir = os.path.join(project_path, "runs")
|
runs_dir = os.path.join(project_path, "runs")
|
||||||
|
|
||||||
# Only copy if explicitly requested AND it's a series (Standalone books get fresh re-rolls)
|
|
||||||
if allow_copy and is_series and os.path.exists(runs_dir):
|
if allow_copy and is_series and os.path.exists(runs_dir):
|
||||||
# Get all run folders except current
|
|
||||||
all_runs = [d for d in os.listdir(runs_dir) if d.startswith("run_") and d != f"run_{run_id}"]
|
all_runs = [d for d in os.listdir(runs_dir) if d.startswith("run_") and d != f"run_{run_id}"]
|
||||||
# Sort by ID (ascending)
|
|
||||||
all_runs.sort(key=lambda x: int(x.split('_')[1]) if x.split('_')[1].isdigit() else 0)
|
all_runs.sort(key=lambda x: int(x.split('_')[1]) if x.split('_')[1].isdigit() else 0)
|
||||||
|
|
||||||
if all_runs:
|
if all_runs:
|
||||||
@@ -145,7 +134,6 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
|
|||||||
|
|
||||||
utils.log("SYSTEM", f"Checking previous run ({all_runs[-1]}) for completed books...")
|
utils.log("SYSTEM", f"Checking previous run ({all_runs[-1]}) for completed books...")
|
||||||
for item in os.listdir(latest_run_dir):
|
for item in os.listdir(latest_run_dir):
|
||||||
# Copy only folders that look like books and have a manuscript
|
|
||||||
if item.startswith("Book_") and os.path.isdir(os.path.join(latest_run_dir, item)):
|
if item.startswith("Book_") and os.path.isdir(os.path.join(latest_run_dir, item)):
|
||||||
if exclude_folders and item in exclude_folders:
|
if exclude_folders and item in exclude_folders:
|
||||||
utils.log("SYSTEM", f" -> Skipping copy of {item} (Target for revision).")
|
utils.log("SYSTEM", f" -> Skipping copy of {item} (Target for revision).")
|
||||||
@@ -161,8 +149,7 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
|
|||||||
utils.log("SYSTEM", f" -> Failed to copy {item}: {e}")
|
utils.log("SYSTEM", f" -> Failed to copy {item}: {e}")
|
||||||
|
|
||||||
# 2. Run Generation
|
# 2. Run Generation
|
||||||
# We call the existing entry point
|
from cli.engine import run_generation
|
||||||
from main import run_generation
|
|
||||||
run_generation(bible_path, specific_run_id=run_id)
|
run_generation(bible_path, specific_run_id=run_id)
|
||||||
|
|
||||||
utils.log("SYSTEM", "Job Complete.")
|
utils.log("SYSTEM", "Job Complete.")
|
||||||
@@ -174,25 +161,20 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
|
|||||||
status = "failed"
|
status = "failed"
|
||||||
|
|
||||||
# 3. Calculate Cost & Cleanup
|
# 3. Calculate Cost & Cleanup
|
||||||
# Use the specific run folder we know main.py used
|
|
||||||
run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
|
run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
|
||||||
|
|
||||||
total_cost = 0.0
|
total_cost = 0.0
|
||||||
final_log_path = initial_log
|
final_log_path = initial_log
|
||||||
|
|
||||||
if os.path.exists(run_dir):
|
if os.path.exists(run_dir):
|
||||||
# Move our log file there
|
|
||||||
final_log_path = os.path.join(run_dir, "web_console.log")
|
final_log_path = os.path.join(run_dir, "web_console.log")
|
||||||
if os.path.exists(initial_log):
|
if os.path.exists(initial_log):
|
||||||
try:
|
try:
|
||||||
os.rename(initial_log, final_log_path)
|
os.rename(initial_log, final_log_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
# If rename fails (e.g. across filesystems), copy and delete
|
|
||||||
shutil.copy2(initial_log, final_log_path)
|
shutil.copy2(initial_log, final_log_path)
|
||||||
os.remove(initial_log)
|
os.remove(initial_log)
|
||||||
|
|
||||||
# Calculate Total Cost from all Book subfolders
|
|
||||||
# usage_log.json is inside each Book folder
|
|
||||||
for item in os.listdir(run_dir):
|
for item in os.listdir(run_dir):
|
||||||
item_path = os.path.join(run_dir, item)
|
item_path = os.path.join(run_dir, item)
|
||||||
if os.path.isdir(item_path) and item.startswith("Book_"):
|
if os.path.isdir(item_path) and item.startswith("Book_"):
|
||||||
@@ -211,39 +193,32 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
|
|||||||
|
|
||||||
return {"run_id": run_id, "status": status, "cost": total_cost, "final_log": final_log_path}
|
return {"run_id": run_id, "status": status, "cost": total_cost, "final_log": final_log_path}
|
||||||
|
|
||||||
|
|
||||||
@huey.task()
|
@huey.task()
|
||||||
def regenerate_artifacts_task(run_id, project_path, feedback=None):
|
def regenerate_artifacts_task(run_id, project_path, feedback=None):
|
||||||
# Hook up Database Logging & Status
|
|
||||||
db_path = os.path.join(config.DATA_DIR, "bookapp.db")
|
db_path = os.path.join(config.DATA_DIR, "bookapp.db")
|
||||||
|
|
||||||
# Determine log file path: Prefer the existing web_console.log in the run dir
|
|
||||||
run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
|
run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
|
||||||
log_file = os.path.join(run_dir, "web_console.log")
|
log_file = os.path.join(run_dir, "web_console.log")
|
||||||
|
|
||||||
# Fallback to project root temp file if run dir doesn't exist (unlikely for regeneration)
|
|
||||||
if not os.path.exists(run_dir):
|
if not os.path.exists(run_dir):
|
||||||
log_file = os.path.join(project_path, f"system_log_{run_id}.txt")
|
log_file = os.path.join(project_path, f"system_log_{run_id}.txt")
|
||||||
|
|
||||||
# Clear previous logs (File) to refresh the log window
|
|
||||||
try:
|
try:
|
||||||
with open(log_file, 'w', encoding='utf-8') as f:
|
with open(log_file, 'w', encoding='utf-8') as f:
|
||||||
f.write(f"[{datetime.utcnow().strftime('%H:%M:%S')}] --- REGENERATION STARTED ---\n")
|
f.write(f"[{datetime.utcnow().strftime('%H:%M:%S')}] --- REGENERATION STARTED ---\n")
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
utils.set_log_file(log_file)
|
utils.set_log_file(log_file)
|
||||||
|
|
||||||
utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m))
|
utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m))
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(db_path) as conn:
|
with sqlite3.connect(db_path) as conn:
|
||||||
conn.execute("DELETE FROM log_entry WHERE run_id = ?", (run_id,)) # Clear DB logs
|
conn.execute("DELETE FROM log_entry WHERE run_id = ?", (run_id,))
|
||||||
conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,))
|
conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,))
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
utils.log("SYSTEM", "Starting Artifact Regeneration...")
|
utils.log("SYSTEM", "Starting Artifact Regeneration...")
|
||||||
|
|
||||||
# 1. Setup Paths
|
|
||||||
|
|
||||||
# Detect Book Subfolder
|
|
||||||
book_dir = run_dir
|
book_dir = run_dir
|
||||||
if os.path.exists(run_dir):
|
if os.path.exists(run_dir):
|
||||||
subdirs = utils.get_sorted_book_folders(run_dir)
|
subdirs = utils.get_sorted_book_folders(run_dir)
|
||||||
@@ -259,7 +234,6 @@ def regenerate_artifacts_task(run_id, project_path, feedback=None):
|
|||||||
except: pass
|
except: pass
|
||||||
return
|
return
|
||||||
|
|
||||||
# 2. Load Data
|
|
||||||
bible = utils.load_json(bible_path)
|
bible = utils.load_json(bible_path)
|
||||||
final_bp_path = os.path.join(book_dir, "final_blueprint.json")
|
final_bp_path = os.path.join(book_dir, "final_blueprint.json")
|
||||||
ms_path = os.path.join(book_dir, "manuscript.json")
|
ms_path = os.path.join(book_dir, "manuscript.json")
|
||||||
@@ -275,17 +249,14 @@ def regenerate_artifacts_task(run_id, project_path, feedback=None):
|
|||||||
bp = utils.load_json(final_bp_path)
|
bp = utils.load_json(final_bp_path)
|
||||||
ms = utils.load_json(ms_path)
|
ms = utils.load_json(ms_path)
|
||||||
|
|
||||||
# 3. Update Blueprint with new Metadata from Bible
|
|
||||||
meta = bible.get('project_metadata', {})
|
meta = bible.get('project_metadata', {})
|
||||||
if 'book_metadata' in bp:
|
if 'book_metadata' in bp:
|
||||||
# Sync all core metadata
|
|
||||||
for k in ['author', 'genre', 'target_audience', 'style']:
|
for k in ['author', 'genre', 'target_audience', 'style']:
|
||||||
if k in meta:
|
if k in meta:
|
||||||
bp['book_metadata'][k] = meta[k]
|
bp['book_metadata'][k] = meta[k]
|
||||||
|
|
||||||
if bp.get('series_metadata', {}).get('is_series'):
|
if bp.get('series_metadata', {}).get('is_series'):
|
||||||
bp['series_metadata']['series_title'] = meta.get('title', bp['series_metadata'].get('series_title'))
|
bp['series_metadata']['series_title'] = meta.get('title', bp['series_metadata'].get('series_title'))
|
||||||
# Find specific book title from Bible
|
|
||||||
b_num = bp['series_metadata'].get('book_number')
|
b_num = bp['series_metadata'].get('book_number')
|
||||||
for b in bible.get('books', []):
|
for b in bible.get('books', []):
|
||||||
if b.get('book_number') == b_num:
|
if b.get('book_number') == b_num:
|
||||||
@@ -296,17 +267,16 @@ def regenerate_artifacts_task(run_id, project_path, feedback=None):
|
|||||||
|
|
||||||
with open(final_bp_path, 'w') as f: json.dump(bp, f, indent=2)
|
with open(final_bp_path, 'w') as f: json.dump(bp, f, indent=2)
|
||||||
|
|
||||||
# 4. Regenerate
|
|
||||||
try:
|
try:
|
||||||
ai.init_models()
|
ai_setup.init_models()
|
||||||
|
|
||||||
tracking = None
|
tracking = None
|
||||||
events_path = os.path.join(book_dir, "tracking_events.json")
|
events_path = os.path.join(book_dir, "tracking_events.json")
|
||||||
if os.path.exists(events_path):
|
if os.path.exists(events_path):
|
||||||
tracking = {"events": utils.load_json(events_path), "characters": utils.load_json(os.path.join(book_dir, "tracking_characters.json"))}
|
tracking = {"events": utils.load_json(events_path), "characters": utils.load_json(os.path.join(book_dir, "tracking_characters.json"))}
|
||||||
|
|
||||||
marketing.generate_cover(bp, book_dir, tracking, feedback=feedback)
|
marketing_cover.generate_cover(bp, book_dir, tracking, feedback=feedback)
|
||||||
export.compile_files(bp, ms, book_dir)
|
exporter.compile_files(bp, ms, book_dir)
|
||||||
|
|
||||||
utils.log("SYSTEM", "Regeneration Complete.")
|
utils.log("SYSTEM", "Regeneration Complete.")
|
||||||
final_status = 'completed'
|
final_status = 'completed'
|
||||||
@@ -319,6 +289,7 @@ def regenerate_artifacts_task(run_id, project_path, feedback=None):
|
|||||||
conn.execute("UPDATE run SET status = ? WHERE id = ?", (final_status, run_id))
|
conn.execute("UPDATE run SET status = ? WHERE id = ?", (final_status, run_id))
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
|
|
||||||
@huey.task()
|
@huey.task()
|
||||||
def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instruction):
|
def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instruction):
|
||||||
"""
|
"""
|
||||||
@@ -327,12 +298,10 @@ def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instructio
|
|||||||
try:
|
try:
|
||||||
run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
|
run_dir = os.path.join(project_path, "runs", f"run_{run_id}")
|
||||||
|
|
||||||
# --- Setup Logging for Rewrite ---
|
|
||||||
log_file = os.path.join(run_dir, "web_console.log")
|
log_file = os.path.join(run_dir, "web_console.log")
|
||||||
if not os.path.exists(log_file):
|
if not os.path.exists(log_file):
|
||||||
log_file = os.path.join(project_path, f"system_log_{run_id}.txt")
|
log_file = os.path.join(project_path, f"system_log_{run_id}.txt")
|
||||||
|
|
||||||
# Clear previous logs to refresh the log window
|
|
||||||
try:
|
try:
|
||||||
with open(log_file, 'w', encoding='utf-8') as f: f.write("")
|
with open(log_file, 'w', encoding='utf-8') as f: f.write("")
|
||||||
except: pass
|
except: pass
|
||||||
@@ -346,10 +315,8 @@ def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instructio
|
|||||||
conn.execute("DELETE FROM log_entry WHERE run_id = ?", (run_id,))
|
conn.execute("DELETE FROM log_entry WHERE run_id = ?", (run_id,))
|
||||||
conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,))
|
conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,))
|
||||||
except: pass
|
except: pass
|
||||||
# ---------------------------------
|
|
||||||
|
|
||||||
book_path = os.path.join(run_dir, book_folder)
|
book_path = os.path.join(run_dir, book_folder)
|
||||||
|
|
||||||
ms_path = os.path.join(book_path, "manuscript.json")
|
ms_path = os.path.join(book_path, "manuscript.json")
|
||||||
bp_path = os.path.join(book_path, "final_blueprint.json")
|
bp_path = os.path.join(book_path, "final_blueprint.json")
|
||||||
|
|
||||||
@@ -360,9 +327,10 @@ def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instructio
|
|||||||
ms = utils.load_json(ms_path)
|
ms = utils.load_json(ms_path)
|
||||||
bp = utils.load_json(bp_path)
|
bp = utils.load_json(bp_path)
|
||||||
|
|
||||||
ai.init_models()
|
ai_setup.init_models()
|
||||||
|
|
||||||
result = story.rewrite_chapter_content(bp, ms, chap_num, instruction, book_path)
|
from story import editor as story_editor
|
||||||
|
result = story_editor.rewrite_chapter_content(bp, ms, chap_num, instruction, book_path)
|
||||||
|
|
||||||
if result and result[0]:
|
if result and result[0]:
|
||||||
new_text, summary = result
|
new_text, summary = result
|
||||||
@@ -371,15 +339,14 @@ def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instructio
|
|||||||
ch['content'] = new_text
|
ch['content'] = new_text
|
||||||
break
|
break
|
||||||
|
|
||||||
# Save the primary rewrite immediately
|
|
||||||
with open(ms_path, 'w') as f: json.dump(ms, f, indent=2)
|
with open(ms_path, 'w') as f: json.dump(ms, f, indent=2)
|
||||||
|
|
||||||
updated_ms = story.check_and_propagate(bp, ms, chap_num, book_path, change_summary=summary)
|
updated_ms = story_editor.check_and_propagate(bp, ms, chap_num, book_path, change_summary=summary)
|
||||||
if updated_ms:
|
if updated_ms:
|
||||||
ms = updated_ms
|
ms = updated_ms
|
||||||
|
|
||||||
with open(ms_path, 'w') as f: json.dump(ms, f, indent=2)
|
with open(ms_path, 'w') as f: json.dump(ms, f, indent=2)
|
||||||
export.compile_files(bp, ms, book_path)
|
exporter.compile_files(bp, ms, book_path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(db_path) as conn:
|
with sqlite3.connect(db_path) as conn:
|
||||||
@@ -387,7 +354,6 @@ def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instructio
|
|||||||
except: pass
|
except: pass
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# If result is False/None
|
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(db_path) as conn:
|
with sqlite3.connect(db_path) as conn:
|
||||||
conn.execute("UPDATE run SET status = 'completed' WHERE id = ?", (run_id,))
|
conn.execute("UPDATE run SET status = 'completed' WHERE id = ?", (run_id,))
|
||||||
@@ -401,6 +367,7 @@ def rewrite_chapter_task(run_id, project_path, book_folder, chap_num, instructio
|
|||||||
except: pass
|
except: pass
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@huey.task()
|
@huey.task()
|
||||||
def refine_bible_task(project_path, instruction, source_type, selected_keys=None):
|
def refine_bible_task(project_path, instruction, source_type, selected_keys=None):
|
||||||
"""
|
"""
|
||||||
@@ -417,25 +384,19 @@ def refine_bible_task(project_path, instruction, source_type, selected_keys=None
|
|||||||
base_bible = utils.load_json(bible_path)
|
base_bible = utils.load_json(bible_path)
|
||||||
if not base_bible: return False
|
if not base_bible: return False
|
||||||
|
|
||||||
# If refining from draft, load it
|
|
||||||
if source_type == 'draft' and os.path.exists(draft_path):
|
if source_type == 'draft' and os.path.exists(draft_path):
|
||||||
draft_bible = utils.load_json(draft_path)
|
draft_bible = utils.load_json(draft_path)
|
||||||
|
|
||||||
# If user selected specific changes, merge them into the base
|
|
||||||
# This creates a "Proposed State" to refine further, WITHOUT modifying bible.json
|
|
||||||
if selected_keys is not None and draft_bible:
|
if selected_keys is not None and draft_bible:
|
||||||
base_bible = story.merge_selected_changes(base_bible, draft_bible, selected_keys)
|
base_bible = bible_tracker.merge_selected_changes(base_bible, draft_bible, selected_keys)
|
||||||
elif draft_bible:
|
elif draft_bible:
|
||||||
# If no specific keys but source is draft, assume we refine the whole draft
|
|
||||||
base_bible = draft_bible
|
base_bible = draft_bible
|
||||||
|
|
||||||
ai.init_models()
|
ai_setup.init_models()
|
||||||
|
|
||||||
# Run AI Refinement
|
new_bible = bible_tracker.refine_bible(base_bible, instruction, project_path)
|
||||||
new_bible = story.refine_bible(base_bible, instruction, project_path)
|
|
||||||
|
|
||||||
if new_bible:
|
if new_bible:
|
||||||
# Save to draft file (Overwrite previous draft)
|
|
||||||
with open(draft_path, 'w') as f: json.dump(new_bible, f, indent=2)
|
with open(draft_path, 'w') as f: json.dump(new_bible, f, indent=2)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
Reference in New Issue
Block a user