Compare commits

...

12 Commits

Author SHA1 Message Date
6f19808f15 fix: Clarify budget is text-only; Imagen cover cost (~$0.12 max) is separate 2026-02-22 10:43:08 -05:00
f1d7fcbcb7 feat: Budget-aware model selection — book cost ceiling with per-role cost calculations 2026-02-22 10:41:22 -05:00
c3724a6761 feat: Cost-aware Pro model selection — free Pro beats Flash, paid Pro loses to Flash 2026-02-22 10:38:57 -05:00
74cc66eed3 feat: Prefer Flash models in auto-selection criteria for cost reduction 2026-02-22 10:33:38 -05:00
353dc859d2 feat: Optimize AI model usage for cost reduction 2026-02-22 10:23:47 -05:00
51b98c9399 refactor: Migrate file-based data storage to database 2026-02-22 10:23:40 -05:00
b4058f9f1f Update README.md to document new Phase 1+2 features
- Chapter navigation (prev/next), bible download, run tagging, run deletion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 10:07:40 -05:00
093e78a89e Add chapter backward/forward navigation in read_book UI
- Each chapter card now has a footer with Prev/Next chapter anchor links
- First chapter shows only Next; last chapter shows 'End of Book'
- Back to Top link on every chapter footer
- Added get_chapter_neighbours() helper in story/bible_tracker.py for
  programmatic chapter sequence navigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 10:06:55 -05:00
bcba67a35f Add orphaned job prevention in generate_book_task
- Guard checks at task start verify: run exists in DB, project folder exists,
  bible.json exists and is parseable, and bible has at least one book
- Any failed check marks the run as 'failed' and returns early, preventing
  jobs from writing to the wrong book or orphaned project directories

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 10:05:59 -05:00
98a330c416 Add run tagging (DB column + migration + route + UI)
- Added tags VARCHAR(300) column to Run model
- Added startup ALTER TABLE migration in app.py
- New POST /run/<id>/set_tags route saves comma-separated tags
- Tag badges + collapsible edit form in run_details.html header area

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 10:05:30 -05:00
af2050160e Add run deletion with filesystem cleanup
- New POST /run/<id>/delete route removes run from DB and deletes run directory
- Only allows deletion of non-active runs (blocks running/queued)
- Delete Run button shown in run_details.html header for non-active runs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 10:04:44 -05:00
203d74f61d Add bible download route and UI button for run details
- New GET /run/<id>/download_bible route serves project bible.json as attachment
- Download Bible button added to run_details.html header toolbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 10:04:11 -05:00
19 changed files with 374 additions and 113 deletions

View File

@@ -96,6 +96,10 @@ Open `http://localhost:5000`.
- **Project Dashboard:** Create and monitor generation jobs from the browser. - **Project Dashboard:** Create and monitor generation jobs from the browser.
- **Real-time Logs:** Console output is streamed to the browser and stored in the database. - **Real-time Logs:** Console output is streamed to the browser and stored in the database.
- **Chapter Editor:** Edit chapters directly in the browser; manual edits are preserved across artifact regenerations and synced back to character/plot tracking state. - **Chapter Editor:** Edit chapters directly in the browser; manual edits are preserved across artifact regenerations and synced back to character/plot tracking state.
- **Chapter Navigation:** Prev/Next buttons on every chapter card in the manuscript reader let you jump between chapters without scrolling.
- **Download Bible:** Download the project's `bible.json` directly from any run's detail page for offline review or cloning.
- **Run Tagging:** Label runs with comma-separated tags (e.g. `dark-ending`, `v2`, `favourite`) to organise and track experiments.
- **Run Deletion:** Delete completed or failed runs and their filesystem data from the run detail page.
- **Cover Regeneration:** Submit written feedback to regenerate the cover image iteratively. - **Cover Regeneration:** Submit written feedback to regenerate the cover image iteratively.
- **Admin Panel:** Manage all users, view spend, and perform factory resets at `/admin`. - **Admin Panel:** Manage all users, view spend, and perform factory resets at `/admin`.
- **Per-User API Keys:** Each user can supply their own Gemini API key; costs are tracked per account. - **Per-User API Keys:** Each user can supply their own Gemini API key; costs are tracked per account.

View File

@@ -27,9 +27,10 @@ model_logic = None
model_writer = None model_writer = None
model_artist = None model_artist = None
model_image = None model_image = None
logic_model_name = "models/gemini-1.5-pro" logic_model_name = "models/gemini-1.5-flash"
writer_model_name = "models/gemini-1.5-flash" writer_model_name = "models/gemini-1.5-flash"
artist_model_name = "models/gemini-1.5-flash" artist_model_name = "models/gemini-1.5-flash"
pro_model_name = "models/gemini-2.0-pro-exp" # Best available Pro for critical rewrites (prefer free/exp)
image_model_name = None image_model_name = None
image_model_source = "None" image_model_source = "None"

View File

@@ -34,9 +34,10 @@ def get_optimal_model(base_type="pro"):
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 Exp (free) for cost-effective logic and JSON adherence.", "estimated_cost": "Free"},
"writer": {"model": "models/gemini-2.0-flash", "reason": "Fallback: Gemini 2.0 Flash for fast, high-quality creative writing.", "estimated_cost": "$0.10/1M"}, "writer": {"model": "models/gemini-2.0-flash", "reason": "Fallback: Gemini 2.0 Flash for fast, high-quality creative writing.", "estimated_cost": "$0.10/1M"},
"artist": {"model": "models/gemini-2.0-flash", "reason": "Fallback: Gemini 2.0 Flash for visual prompt design.", "estimated_cost": "$0.10/1M"}, "artist": {"model": "models/gemini-2.0-flash", "reason": "Fallback: Gemini 2.0 Flash for visual prompt design.", "estimated_cost": "$0.10/1M"},
"pro_rewrite": {"model": "models/gemini-2.0-pro-exp", "reason": "Fallback: Gemini 2.0 Pro Exp (free) for critical chapter rewrites.", "estimated_cost": "Free"},
"ranking": [] "ranking": []
} }
@@ -73,37 +74,59 @@ def select_best_models(force_refresh=False):
model = genai.GenerativeModel(bootstrapper) model = genai.GenerativeModel(bootstrapper)
prompt = f""" prompt = f"""
ROLE: AI Model Architect ROLE: AI Model Architect
TASK: Select the optimal Gemini models for a book-writing application. Prefer newer Gemini 2.x models when available. TASK: Select the optimal Gemini models for a book-writing application.
PRIMARY OBJECTIVE: Keep total book generation cost under $2.00. Quality is secondary to this budget.
AVAILABLE_MODELS: AVAILABLE_MODELS:
{json.dumps(compatible)} {json.dumps(compatible)}
PRICING_CONTEXT (USD per 1M tokens, approximate): PRICING_CONTEXT (USD per 1M tokens — use these to calculate actual book cost):
- Gemini 2.5 Pro/Flash: Best quality/speed; check current pricing. - FREE TIER: Any model with 'exp', 'beta', or 'preview' in name = $0.00. Always prefer these.
- Gemini 2.0 Flash: ~$0.10 Input / $0.40 Output. (Fast, cost-effective, excellent quality). e.g. gemini-2.0-pro-exp = FREE, gemini-2.5-pro-preview = FREE.
- Gemini 2.0 Pro Exp: Free experimental tier with strong reasoning. - gemini-2.5-flash / gemini-2.5-flash-preview: ~$0.075 Input / $0.30 Output.
- Gemini 1.5 Flash: ~$0.075 Input / $0.30 Output. (Legacy, still reliable). - gemini-2.0-flash: ~$0.10 Input / $0.40 Output.
- Gemini 1.5 Pro: ~$1.25 Input / $5.00 Output. (Legacy, expensive). - gemini-1.5-flash: ~$0.075 Input / $0.30 Output.
- gemini-2.5-pro (stable, non-preview): ~$1.25 Input / $10.00 Output. BUDGET BREAKER.
- gemini-1.5-pro (stable): ~$1.25 Input / $5.00 Output. BUDGET BREAKER.
CRITERIA: BOOK TOKEN BUDGET (30-chapter novel — use this to calculate real cost before deciding):
- LOGIC: Needs complex reasoning, strict JSON adherence, plot consistency, and instruction following. Logic role total: ~265,000 input tokens + ~55,000 output tokens
-> Prefer: Gemini 2.5 Pro > 2.0 Pro > 2.0 Flash > 1.5 Pro (planning, state tracking, consistency checks, director treatments per chapter)
- WRITER: Needs creativity, prose quality, long-form text generation, and speed. Writer role total: ~450,000 input tokens + ~135,000 output tokens
-> Prefer: Gemini 2.5 Flash/Pro > 2.0 Flash > 1.5 Flash (balance quality/cost) (drafting, evaluation, refinement per chapter — 2 passes max)
- ARTIST: Needs rich visual description, prompt understanding for cover art design. Artist role total: ~30,000 input tokens + ~8,000 output tokens
-> Prefer: Gemini 2.0 Flash > 1.5 Flash (speed and visual understanding) (cover art prompt design, cover layout, blurb, image quality evaluation — text calls only)
CONSTRAINTS: NOTE: Cover IMAGE generation uses the Imagen API (billed per image, not per token).
- Strongly prefer Gemini 2.x over 1.5 where available. Imagen costs are fixed at ~$0.04/image × up to 3 attempts = ~$0.12 max. This is SEPARATE
- Avoid 'experimental' or 'preview' only if a stable 2.x version exists; otherwise experimental 2.x is fine. from the text token budget below and cannot be reduced by model selection.
- 'thinking' models are too slow/expensive for Writer/Artist roles.
- Provide a ranking of ALL available models from best to worst overall. COST FORMULA: cost = (input_tokens / 1,000,000 * input_price) + (output_tokens / 1,000,000 * output_price)
HARD BUDGET: Logic_cost + Writer_cost + Artist_cost (text only) must be < $1.85
(leaving $0.15 headroom for Imagen cover generation, total book target: $2.00).
SELECTION RULES (apply in order):
1. FREE FIRST: If a free/exp model exists (any tier, any quality), pick it for Logic. Cost = $0.
2. FLASH FOR WRITER: Flash is sufficient for fiction prose. Never pick a paid Pro for Writer.
3. CALCULATE: For non-free models, compute the actual book cost using the token budget above.
Reject any combination that exceeds $2.00 total.
4. QUALITY TIEBREAK: Among models with similar cost, prefer newer generation (2.x > 1.5).
5. NO THINKING MODELS: Too slow and expensive for any role.
ROLES:
- LOGIC: Planning, JSON adherence, plot consistency. Free/exp Pro ideal; Flash acceptable.
- WRITER: Creative prose, chapter drafting. Flash 2.x is sufficient — do NOT use paid Pro.
- ARTIST: Visual prompts for cover art. Cheapest capable Flash model.
- PRO_REWRITE: Emergency full-chapter rewrite (rare, ~1-2x per book). Best free/exp Pro available.
If no free Pro exists, use best Flash — do not use paid Pro even here.
OUTPUT_FORMAT (JSON only, no markdown): OUTPUT_FORMAT (JSON only, no markdown):
{{ {{
"logic": {{ "model": "string", "reason": "string", "estimated_cost": "$X.XX/1M" }}, "logic": {{ "model": "string", "reason": "string", "estimated_cost": "$X.XX/1M", "book_cost": "$X.XX" }},
"writer": {{ "model": "string", "reason": "string", "estimated_cost": "$X.XX/1M" }}, "writer": {{ "model": "string", "reason": "string", "estimated_cost": "$X.XX/1M", "book_cost": "$X.XX" }},
"artist": {{ "model": "string", "reason": "string", "estimated_cost": "$X.XX/1M" }}, "artist": {{ "model": "string", "reason": "string", "estimated_cost": "$X.XX/1M", "book_cost": "$X.XX" }},
"pro_rewrite": {{ "model": "string", "reason": "string", "estimated_cost": "$X.XX/1M", "book_cost": "$X.XX" }},
"total_estimated_book_cost": "$X.XX",
"ranking": [ {{ "model": "string", "reason": "string", "estimated_cost": "string" }} ] "ranking": [ {{ "model": "string", "reason": "string", "estimated_cost": "string" }} ]
}} }}
""" """
@@ -173,19 +196,27 @@ def init_models(force=False):
if not force: if not force:
missing_costs = False missing_costs = False
for role in ['logic', 'writer', 'artist']: for role in ['logic', 'writer', 'artist']:
if 'estimated_cost' not in selected_models.get(role, {}) or selected_models[role].get('estimated_cost') == 'N/A': role_data = selected_models.get(role, {})
if 'estimated_cost' not in role_data or role_data.get('estimated_cost') == 'N/A':
missing_costs = True missing_costs = True
if 'book_cost' not in role_data:
missing_costs = True
if 'total_estimated_book_cost' not in selected_models:
missing_costs = True
if missing_costs: if missing_costs:
utils.log("SYSTEM", "⚠️ Missing cost info in cached models. Forcing refresh.") utils.log("SYSTEM", "⚠️ Missing cost info in cached models. Forcing refresh.")
return init_models(force=True) return init_models(force=True)
def get_model_details(role_data): def get_model_details(role_data):
if isinstance(role_data, dict): return role_data.get('model'), role_data.get('estimated_cost', 'N/A') if isinstance(role_data, dict):
return role_data, 'N/A' return role_data.get('model'), role_data.get('estimated_cost', 'N/A'), role_data.get('book_cost', 'N/A')
return role_data, 'N/A', 'N/A'
logic_name, logic_cost = get_model_details(selected_models['logic']) logic_name, logic_cost, logic_book = get_model_details(selected_models['logic'])
writer_name, writer_cost = get_model_details(selected_models['writer']) writer_name, writer_cost, writer_book = get_model_details(selected_models['writer'])
artist_name, artist_cost = get_model_details(selected_models['artist']) artist_name, artist_cost, artist_book = get_model_details(selected_models['artist'])
pro_name, pro_cost, _ = get_model_details(selected_models.get('pro_rewrite', {'model': 'models/gemini-2.0-pro-exp', 'estimated_cost': 'Free', 'book_cost': '$0.00'}))
total_book_cost = selected_models.get('total_estimated_book_cost', 'N/A')
logic_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_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
@@ -194,8 +225,10 @@ def init_models(force=False):
models.logic_model_name = logic_name models.logic_model_name = logic_name
models.writer_model_name = writer_name models.writer_model_name = writer_name
models.artist_model_name = artist_name models.artist_model_name = artist_name
models.pro_model_name = pro_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}, {logic_book}/book) | Writer={writer_name} ({writer_cost}, {writer_book}/book) | Artist={artist_name} | Pro-Rewrite={pro_name} ({pro_cost})")
utils.log("SYSTEM", f"💰 Estimated book cost: {total_book_cost} text + ~$0.00-$0.12 Imagen cover (budget: $2.00 total)")
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)

View File

@@ -80,9 +80,9 @@ class BookWizard:
while True: while True:
self.clear() self.clear()
personas = {} personas = {}
if os.path.exists(config.PERSONAS_FILE): if os.path.exists(os.path.join(config.PERSONAS_DIR, "personas.json")):
try: try:
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f) with open(os.path.join(config.PERSONAS_DIR, "personas.json"), '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]"))
@@ -120,7 +120,7 @@ class BookWizard:
if sub == 2: if sub == 2:
if Confirm.ask(f"Delete '{selected_key}'?", default=False): if Confirm.ask(f"Delete '{selected_key}'?", default=False):
del personas[selected_key] del personas[selected_key]
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2) with open(os.path.join(config.PERSONAS_DIR, "personas.json"), 'w') as f: json.dump(personas, f, indent=2)
continue continue
elif sub == 3: elif sub == 3:
continue continue
@@ -145,7 +145,7 @@ class BookWizard:
if Confirm.ask("Save Persona?", default=True): if Confirm.ask("Save Persona?", default=True):
personas[selected_key] = details personas[selected_key] = details
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2) with open(os.path.join(config.PERSONAS_DIR, "personas.json"), 'w') as f: json.dump(personas, f, indent=2)
def select_mode(self): def select_mode(self):
while True: while True:
@@ -322,9 +322,9 @@ class BookWizard:
console.print("\n[bold blue]Project Details[/bold blue]") console.print("\n[bold blue]Project Details[/bold blue]")
personas = {} personas = {}
if os.path.exists(config.PERSONAS_FILE): if os.path.exists(os.path.join(config.PERSONAS_DIR, "personas.json")):
try: try:
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f) with open(os.path.join(config.PERSONAS_DIR, "personas.json"), 'r') as f: personas = json.load(f)
except: pass except: pass
author_details = {} author_details = {}

View File

@@ -35,7 +35,8 @@ if not API_KEY: raise ValueError("CRITICAL ERROR: GEMINI_API_KEY not found in en
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")
PERSONAS_FILE = os.path.join(PERSONAS_DIR, "personas.json") # PERSONAS_FILE is deprecated — persona data is now stored in the Persona DB table.
# 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 ---

View File

@@ -129,11 +129,8 @@ 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():
# Persona data is now stored in the Persona DB table; ensure the directory exists for sample files.
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):
try:
with open(config.PERSONAS_FILE, 'w') as f: json.dump({}, f, indent=2)
except: pass
def get_length_presets(): def get_length_presets():
presets = {} presets = {}

View File

@@ -157,6 +157,21 @@ def harvest_metadata(bp, folder, full_manuscript):
return bp return bp
def get_chapter_neighbours(manuscript, current_num):
"""Return (prev_num, next_num) chapter numbers adjacent to current_num.
manuscript: list of chapter dicts each with a 'num' key.
Returns None for prev/next when at the boundary.
"""
nums = sorted({ch.get('num') for ch in manuscript if ch.get('num') is not None})
if current_num not in nums:
return None, None
idx = nums.index(current_num)
prev_num = nums[idx - 1] if idx > 0 else None
next_num = nums[idx + 1] if idx < len(nums) - 1 else None
return prev_num, next_num
def refine_bible(bible, instruction, folder): def refine_bible(bible, instruction, folder):
utils.log("SYSTEM", f"Refining Bible with instruction: {instruction}") utils.log("SYSTEM", f"Refining Bible with instruction: {instruction}")
prompt = f""" prompt = f"""

View File

@@ -8,17 +8,27 @@ def _empty_state():
return {"active_threads": [], "immediate_handoff": "", "resolved_threads": [], "chapter": 0} return {"active_threads": [], "immediate_handoff": "", "resolved_threads": [], "chapter": 0}
def load_story_state(folder): def load_story_state(folder, project_id=None):
"""Load structured story state from story_state.json, or return empty state.""" """Load structured story state from DB (if project_id given) or story_state.json fallback."""
if project_id is not None:
try:
from web.db import StoryState
record = StoryState.query.filter_by(project_id=project_id).first()
if record and record.state_json:
return json.loads(record.state_json) or _empty_state()
except Exception:
pass # Fall through to file-based load if DB unavailable (e.g. CLI context)
path = os.path.join(folder, "story_state.json") path = os.path.join(folder, "story_state.json")
if os.path.exists(path): if os.path.exists(path):
return utils.load_json(path) or _empty_state() return utils.load_json(path) or _empty_state()
return _empty_state() return _empty_state()
def update_story_state(chapter_text, chapter_num, current_state, folder): def update_story_state(chapter_text, chapter_num, current_state, folder, project_id=None):
"""Use model_logic to extract structured story threads from the new chapter """Use model_logic to extract structured story threads from the new chapter
and save the updated state to story_state.json. Returns the new state.""" and save the updated state to the StoryState DB table and/or story_state.json.
Returns the new state."""
utils.log("STATE", f"Updating story state after Ch {chapter_num}...") utils.log("STATE", f"Updating story state after Ch {chapter_num}...")
prompt = f""" prompt = f"""
ROLE: Story State Tracker ROLE: Story State Tracker
@@ -54,9 +64,28 @@ def update_story_state(chapter_text, chapter_num, current_state, folder):
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata) utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
new_state = json.loads(utils.clean_json(response.text)) new_state = json.loads(utils.clean_json(response.text))
new_state['chapter'] = chapter_num new_state['chapter'] = chapter_num
# Write to DB if project_id is available
if project_id is not None:
try:
from web.db import db, StoryState
from datetime import datetime
record = StoryState.query.filter_by(project_id=project_id).first()
if record:
record.state_json = json.dumps(new_state)
record.updated_at = datetime.utcnow()
else:
record = StoryState(project_id=project_id, state_json=json.dumps(new_state))
db.session.add(record)
db.session.commit()
except Exception as db_err:
utils.log("STATE", f" -> DB write failed: {db_err}. Falling back to file.")
# Always write to file for backward compat with CLI
path = os.path.join(folder, "story_state.json") path = os.path.join(folder, "story_state.json")
with open(path, 'w') as f: with open(path, 'w') as f:
json.dump(new_state, f, indent=2) json.dump(new_state, f, indent=2)
utils.log("STATE", f" -> Story state saved. Active threads: {len(new_state.get('active_threads', []))}") utils.log("STATE", f" -> Story state saved. Active threads: {len(new_state.get('active_threads', []))}")
return new_state return new_state
except Exception as e: except Exception as e:

View File

@@ -157,10 +157,12 @@ def update_persona_sample(bp, folder):
author_name = meta.get('author', 'Unknown Author') author_name = meta.get('author', 'Unknown Author')
# Use a local file mirror for the engine context (runs outside Flask app context)
_personas_file = os.path.join(config.PERSONAS_DIR, "personas.json")
personas = {} personas = {}
if os.path.exists(config.PERSONAS_FILE): if os.path.exists(_personas_file):
try: try:
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f) with open(_personas_file, 'r') as f: personas = json.load(f)
except: pass except: pass
if author_name not in personas: if author_name not in personas:
@@ -189,4 +191,4 @@ def update_persona_sample(bp, folder):
if filename not in personas[author_name]['sample_files']: if filename not in personas[author_name]['sample_files']:
personas[author_name]['sample_files'].append(filename) personas[author_name]['sample_files'].append(filename)
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2) with open(_personas_file, 'w') as f: json.dump(personas, f, indent=2)

View File

@@ -327,7 +327,7 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None,
utils.log("WRITER", f"⚠️ Failed Ch {chap['chapter_number']}: {e}") utils.log("WRITER", f"⚠️ Failed Ch {chap['chapter_number']}: {e}")
return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}" return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}"
max_attempts = 5 max_attempts = 2
SCORE_AUTO_ACCEPT = 8 SCORE_AUTO_ACCEPT = 8
SCORE_PASSING = 7 SCORE_PASSING = 7
SCORE_REWRITE_THRESHOLD = 6 SCORE_REWRITE_THRESHOLD = 6
@@ -378,11 +378,15 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None,
""" """
try: try:
_pro = getattr(ai_models, 'pro_model_name', 'models/gemini-2.0-pro-exp')
ai_models.model_logic.update(_pro)
resp_rewrite = ai_models.model_logic.generate_content(full_rewrite_prompt) resp_rewrite = ai_models.model_logic.generate_content(full_rewrite_prompt)
utils.log_usage(folder, ai_models.model_logic.name, resp_rewrite.usage_metadata) utils.log_usage(folder, ai_models.model_logic.name, resp_rewrite.usage_metadata)
current_text = resp_rewrite.text current_text = resp_rewrite.text
ai_models.model_logic.update(ai_models.logic_model_name)
continue continue
except Exception as e: except Exception as e:
ai_models.model_logic.update(ai_models.logic_model_name)
utils.log("WRITER", f"Full rewrite failed: {e}. Falling back to refinement.") utils.log("WRITER", f"Full rewrite failed: {e}. Falling back to refinement.")
utils.log("WRITER", f" -> Refining Ch {chap['chapter_number']} based on feedback...") utils.log("WRITER", f" -> Refining Ch {chap['chapter_number']} based on feedback...")

View File

@@ -48,6 +48,27 @@
</div> </div>
</div> </div>
<!-- Chapter Navigation Footer -->
<div class="card-footer bg-transparent d-flex justify-content-between align-items-center py-2">
{% if not loop.first %}
{% set prev_ch = manuscript[loop.index0 - 1] %}
<a href="#ch-{{ prev_ch.num }}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-up me-1"></i>Ch {{ prev_ch.num }}
</a>
{% else %}
<span></span>
{% endif %}
<a href="#" class="btn btn-sm btn-link text-muted small py-0">Back to Top</a>
{% if not loop.last %}
{% set next_ch = manuscript[loop.index0 + 1] %}
<a href="#ch-{{ next_ch.num }}" class="btn btn-sm btn-outline-secondary">
Ch {{ next_ch.num }}<i class="fas fa-arrow-down ms-1"></i>
</a>
{% else %}
<span class="text-muted small fst-italic">End of Book</span>
{% endif %}
</div>
<!-- Rewrite Modal --> <!-- Rewrite Modal -->
<div class="modal fade" id="rewriteModal{{ ch.num|string|replace(' ', '') }}" tabindex="-1"> <div class="modal fade" id="rewriteModal{{ ch.num|string|replace(' ', '') }}" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">

View File

@@ -10,10 +10,21 @@
<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">
<i class="fas fa-scroll me-2"></i>Show Bible <i class="fas fa-scroll me-2"></i>Show Bible
</button> </button>
<a href="{{ url_for('run.download_bible', id=run.id) }}" class="btn btn-outline-info me-2" title="Download the project bible (JSON) used for this run.">
<i class="fas fa-file-download me-2"></i>Download Bible
</a>
<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('project.view_project', id=run.project_id) }}" class="btn btn-outline-secondary">Back to Project</a> {% if run.status not in ['running', 'queued'] %}
<form action="{{ url_for('run.delete_run', id=run.id) }}" method="POST" class="d-inline ms-2"
onsubmit="return confirm('Delete Run #{{ run.id }} and all its files? This cannot be undone.');">
<button type="submit" class="btn btn-outline-danger">
<i class="fas fa-trash me-2"></i>Delete Run
</button>
</form>
{% endif %}
<a href="{{ url_for('project.view_project', id=run.project_id) }}" class="btn btn-outline-secondary ms-2">Back to Project</a>
</div> </div>
</div> </div>
@@ -97,6 +108,28 @@
</div> </div>
</div> </div>
<!-- Tags -->
<div class="mb-3 d-flex align-items-center gap-2 flex-wrap">
{% if run.tags %}
{% for tag in run.tags.split(',') %}
<span class="badge bg-secondary fs-6">{{ tag }}</span>
{% endfor %}
{% else %}
<span class="text-muted small fst-italic">No tags</span>
{% endif %}
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="collapse" data-bs-target="#tagsForm">
<i class="fas fa-tag me-1"></i>Edit Tags
</button>
<div class="collapse w-100" id="tagsForm">
<form action="{{ url_for('run.set_tags', id=run.id) }}" method="POST" class="d-flex gap-2 mt-1">
<input type="text" name="tags" class="form-control form-control-sm"
value="{{ run.tags or '' }}"
placeholder="comma-separated tags, e.g. dark-ending, v2, favourite">
<button type="submit" class="btn btn-sm btn-primary">Save</button>
</form>
</div>
</div>
<!-- Status Bar --> <!-- Status Bar -->
<div class="card shadow-sm mb-4"> <div class="card shadow-sm mb-4">
<div class="card-body"> <div class="card-body">

View File

@@ -116,6 +116,14 @@ with app.app_context():
_log("System: Added 'last_heartbeat' column to Run table.") _log("System: Added 'last_heartbeat' column to Run table.")
except: pass except: pass
# Migration: Add 'tags' column if missing
try:
with db.engine.connect() as conn:
conn.execute(text("ALTER TABLE run ADD COLUMN tags VARCHAR(300)"))
conn.commit()
_log("System: Added 'tags' column to Run table.")
except: pass
# Reset all non-terminal runs on startup (running, queued, interrupted) # Reset all non-terminal runs on startup (running, queued, interrupted)
# The Huey consumer restarts with the app, so any in-flight tasks are gone. # The Huey consumer restarts with the app, so any in-flight tasks are gone.
try: try:

View File

@@ -35,6 +35,8 @@ class Run(db.Model):
progress = db.Column(db.Integer, default=0) progress = db.Column(db.Integer, default=0)
last_heartbeat = db.Column(db.DateTime, nullable=True) last_heartbeat = db.Column(db.DateTime, nullable=True)
tags = db.Column(db.String(300), nullable=True)
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):
@@ -49,3 +51,16 @@ class LogEntry(db.Model):
timestamp = db.Column(db.DateTime, default=datetime.utcnow) timestamp = db.Column(db.DateTime, default=datetime.utcnow)
phase = db.Column(db.String(50)) phase = db.Column(db.String(50))
message = db.Column(db.Text) message = db.Column(db.Text)
class StoryState(db.Model):
id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False)
state_json = db.Column(db.Text, nullable=True)
updated_at = db.Column(db.DateTime, default=datetime.utcnow)
class Persona(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), unique=True, nullable=False)
details_json = db.Column(db.Text, nullable=True)

View File

@@ -5,7 +5,7 @@ from datetime import datetime, timedelta
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
from flask_login import login_required, login_user, current_user from flask_login import login_required, login_user, current_user
from sqlalchemy import func from sqlalchemy import func
from web.db import db, User, Project, Run from web.db import db, User, Project, Run, Persona
from web.helpers import admin_required from web.helpers import admin_required
from core import config, utils from core import config, utils
from ai import models as ai_models from ai import models as ai_models
@@ -83,10 +83,7 @@ def admin_factory_reset():
except: pass except: pass
db.session.delete(u) db.session.delete(u)
if os.path.exists(config.PERSONAS_FILE): Persona.query.delete()
try: os.remove(config.PERSONAS_FILE)
except: pass
utils.create_default_personas()
db.session.commit() db.session.commit()
flash("Factory Reset Complete. All other users and projects have been wiped.") flash("Factory Reset Complete. All other users and projects have been wiped.")

View File

@@ -1,22 +1,31 @@
import os
import json import json
from flask import Blueprint, render_template, request, redirect, url_for, flash from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_required from flask_login import login_required
from core import config, utils from core import utils
from ai import models as ai_models from ai import models as ai_models
from ai import setup as ai_setup from ai import setup as ai_setup
from web.db import db, Persona
persona_bp = Blueprint('persona', __name__) persona_bp = Blueprint('persona', __name__)
def _all_personas_dict():
"""Return all personas as a dict keyed by name, matching the old personas.json structure."""
records = Persona.query.all()
result = {}
for rec in records:
try:
details = json.loads(rec.details_json) if rec.details_json else {}
except Exception:
details = {}
result[rec.name] = details
return result
@persona_bp.route('/personas') @persona_bp.route('/personas')
@login_required @login_required
def list_personas(): def list_personas():
personas = {} personas = _all_personas_dict()
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) return render_template('personas.html', personas=personas)
@@ -29,17 +38,16 @@ def new_persona():
@persona_bp.route('/persona/<string:name>') @persona_bp.route('/persona/<string:name>')
@login_required @login_required
def edit_persona(name): def edit_persona(name):
personas = {} record = Persona.query.filter_by(name=name).first()
if os.path.exists(config.PERSONAS_FILE): if not record:
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.") flash(f"Persona '{name}' not found.")
return redirect(url_for('persona.list_personas')) return redirect(url_for('persona.list_personas'))
try:
persona = json.loads(record.details_json) if record.details_json else {}
except Exception:
persona = {}
return render_template('persona_edit.html', persona=persona, name=name) return render_template('persona_edit.html', persona=persona, name=name)
@@ -53,16 +61,7 @@ def save_persona():
flash("Persona name is required.") flash("Persona name is required.")
return redirect(url_for('persona.list_personas')) return redirect(url_for('persona.list_personas'))
personas = {} persona_data = {
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, "name": name,
"bio": request.form.get('bio'), "bio": request.form.get('bio'),
"age": request.form.get('age'), "age": request.form.get('age'),
@@ -75,10 +74,21 @@ def save_persona():
"style_inspirations": request.form.get('style_inspirations') "style_inspirations": request.form.get('style_inspirations')
} }
personas[name] = persona # If name changed, remove old record
if old_name and old_name != name:
old_record = Persona.query.filter_by(name=old_name).first()
if old_record:
db.session.delete(old_record)
db.session.flush()
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2) record = Persona.query.filter_by(name=name).first()
if record:
record.details_json = json.dumps(persona_data)
else:
record = Persona(name=name, details_json=json.dumps(persona_data))
db.session.add(record)
db.session.commit()
flash(f"Persona '{name}' saved.") flash(f"Persona '{name}' saved.")
return redirect(url_for('persona.list_personas')) return redirect(url_for('persona.list_personas'))
@@ -86,15 +96,10 @@ def save_persona():
@persona_bp.route('/persona/delete/<string:name>', methods=['POST']) @persona_bp.route('/persona/delete/<string:name>', methods=['POST'])
@login_required @login_required
def delete_persona(name): def delete_persona(name):
personas = {} record = Persona.query.filter_by(name=name).first()
if os.path.exists(config.PERSONAS_FILE): if record:
try: db.session.delete(record)
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f) db.session.commit()
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.") flash(f"Persona '{name}' deleted.")
return redirect(url_for('persona.list_personas')) return redirect(url_for('persona.list_personas'))

View File

@@ -4,7 +4,7 @@ import shutil
from datetime import datetime from datetime import datetime
from flask import Blueprint, render_template, request, redirect, url_for, flash from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from web.db import db, Project, Run from web.db import db, Project, Run, Persona
from web.helpers import is_project_locked from web.helpers import is_project_locked
from core import config, utils from core import config, utils
from ai import models as ai_models from ai import models as ai_models
@@ -104,11 +104,7 @@ def project_setup_wizard():
flash(f"AI Analysis failed — fill in the details manually. ({e})", "warning") flash(f"AI Analysis failed — fill in the details manually. ({e})", "warning")
suggestions = _default_suggestions suggestions = _default_suggestions
personas = {} personas = {rec.name: (json.loads(rec.details_json) if rec.details_json else {}) for rec in Persona.query.all()}
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) return render_template('project_setup.html', s=suggestions, concept=concept, personas=personas, lengths=config.LENGTH_DEFINITIONS)
@@ -149,11 +145,7 @@ def project_setup_refine():
flash(f"Refinement failed: {e}") flash(f"Refinement failed: {e}")
return redirect(url_for('project.index')) return redirect(url_for('project.index'))
personas = {} personas = {rec.name: (json.loads(rec.details_json) if rec.details_json else {}) for rec in Persona.query.all()}
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) return render_template('project_setup.html', s=suggestions, concept=concept, personas=personas, lengths=config.LENGTH_DEFINITIONS)
@@ -329,11 +321,7 @@ def view_project(id):
has_draft = os.path.exists(draft_path) has_draft = os.path.exists(draft_path)
is_refining = os.path.exists(os.path.join(proj.folder_path, ".refining")) is_refining = os.path.exists(os.path.join(proj.folder_path, ".refining"))
personas = {} personas = {rec.name: (json.loads(rec.details_json) if rec.details_json else {}) for rec in Persona.query.all()}
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() runs = Run.query.filter_by(project_id=id).order_by(Run.id.desc()).all()
latest_run = runs[0] if runs else None latest_run = runs[0] if runs else None
@@ -730,11 +718,7 @@ def set_project_persona(id):
bible = utils.load_json(bible_path) bible = utils.load_json(bible_path)
if bible: if bible:
personas = {} personas = {rec.name: (json.loads(rec.details_json) if rec.details_json else {}) for rec in Persona.query.all()}
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: if persona_name in personas:
bible['project_metadata']['author_details'] = personas[persona_name] bible['project_metadata']['author_details'] = personas[persona_name]

View File

@@ -1,5 +1,6 @@
import os import os
import json import json
import shutil
import markdown import markdown
from datetime import datetime from datetime import datetime
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, send_from_directory from flask import Blueprint, render_template, request, redirect, url_for, flash, session, send_from_directory
@@ -393,6 +394,67 @@ def revise_book(run_id, book_folder):
return redirect(url_for('run.view_run', id=new_run.id)) return redirect(url_for('run.view_run', id=new_run.id))
@run_bp.route('/run/<int:id>/set_tags', methods=['POST'])
@login_required
def set_tags(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
raw = request.form.get('tags', '')
tags = [t.strip() for t in raw.split(',') if t.strip()]
run.tags = ','.join(dict.fromkeys(tags))
db.session.commit()
flash("Tags updated.")
return redirect(url_for('run.view_run', id=id))
@run_bp.route('/run/<int:id>/delete', methods=['POST'])
@login_required
def delete_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
if run.status in ['running', 'queued']:
flash("Cannot delete an active run. Stop it first.")
return redirect(url_for('run.view_run', id=id))
project_id = run.project_id
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
if os.path.exists(run_dir):
shutil.rmtree(run_dir)
db.session.delete(run)
db.session.commit()
flash(f"Run #{id} deleted successfully.")
return redirect(url_for('project.view_project', id=project_id))
@run_bp.route('/run/<int:id>/download_bible')
@login_required
def download_bible(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
bible_path = os.path.join(run.project.folder_path, "bible.json")
if not os.path.exists(bible_path):
return "Bible file not found", 404
safe_name = utils.sanitize_filename(run.project.name or "project")
download_name = f"bible_{safe_name}.json"
return send_from_directory(
os.path.dirname(bible_path),
os.path.basename(bible_path),
as_attachment=True,
download_name=download_name
)
@run_bp.route('/project/<int:run_id>/regenerate_artifacts', methods=['POST']) @run_bp.route('/project/<int:run_id>/regenerate_artifacts', methods=['POST'])
@login_required @login_required
def regenerate_artifacts(run_id): def regenerate_artifacts(run_id):

View File

@@ -107,6 +107,56 @@ def generate_book_task(run_id, project_path, bible_path, allow_copy=True, feedba
_task_log(f"Task picked up by Huey worker. project_path={project_path}") _task_log(f"Task picked up by Huey worker. project_path={project_path}")
# 0. Orphaned Job Guard — verify that all required resources exist before
# doing any work. If a run, project folder, or bible is missing, terminate
# silently and mark the run as failed to prevent data being written to the
# wrong book or project.
db_path_early = os.path.join(config.DATA_DIR, "bookapp.db")
try:
with sqlite3.connect(db_path_early, timeout=10) as _conn:
_row = _conn.execute("SELECT id FROM run WHERE id = ?", (run_id,)).fetchone()
if not _row:
_task_log(f"ABORT: Run #{run_id} no longer exists in DB. Terminating silently.")
return
except Exception as _e:
_task_log(f"WARNING: Could not verify run #{run_id} existence: {_e}")
if not os.path.isdir(project_path):
_task_log(f"ABORT: Project folder missing ({project_path}). Marking run #{run_id} as failed.")
try:
_robust_update_run_status(db_path_early, run_id, 'failed',
end_time=datetime.utcnow().isoformat())
except Exception: pass
return
if not os.path.isfile(bible_path):
_task_log(f"ABORT: Bible file missing ({bible_path}). Marking run #{run_id} as failed.")
try:
_robust_update_run_status(db_path_early, run_id, 'failed',
end_time=datetime.utcnow().isoformat())
except Exception: pass
return
# Validate that the bible has at least one book entry
try:
with open(bible_path, 'r', encoding='utf-8') as _bf:
_bible_check = json.load(_bf)
if not _bible_check.get('books'):
_task_log(f"ABORT: Bible has no books defined. Marking run #{run_id} as failed.")
try:
_robust_update_run_status(db_path_early, run_id, 'failed',
end_time=datetime.utcnow().isoformat())
except Exception: pass
return
except Exception as _e:
_task_log(f"ABORT: Could not parse bible ({bible_path}): {_e}. Marking run #{run_id} as failed.")
try:
_robust_update_run_status(db_path_early, run_id, 'failed',
end_time=datetime.utcnow().isoformat())
except Exception: pass
return
# 1. Setup Logging # 1. Setup Logging
log_filename = f"system_log_{run_id}.txt" log_filename = f"system_log_{run_id}.txt"