From c2e7ed01b478d8d687cd2a80fbff97f19678cf49 Mon Sep 17 00:00:00 2001 From: Mike Wichers Date: Tue, 3 Feb 2026 13:49:49 -0500 Subject: [PATCH] Fixes for site --- README.md | 13 ++- modules/ai.py | 13 +++ modules/story.py | 152 ++++++++++++++++++++++++++++----- modules/web_app.py | 85 +++++++++++++++--- templates/admin_dashboard.html | 10 +++ templates/admin_style.html | 48 +++++++++++ templates/project.html | 11 ++- templates/run_details.html | 94 +++++++++++--------- wizard.py | 2 + 9 files changed, 346 insertions(+), 82 deletions(-) create mode 100644 templates/admin_style.html diff --git a/README.md b/README.md index d449cdc..20d0638 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,12 @@ This is the best way to run the Web Dashboard on a server using Portainer. ### 1. Git Setup 1. Create a new Git repository (GitHub/GitLab). + - For local servers like Gitea, your URL will be something like `http://10.0.0.102:3000/thethreemagi/bookapp.git`. 2. Push this project code to the repository. - **IMPORTANT:** Ensure `.env`, `token.json`, `credentials.json`, and the `data/` folder are in your `.gitignore`. Do **not** commit secrets to the repo. ### 2. Server Preparation (One-Time Setup) + Since secrets and database files shouldn't be in Git, you need to place them on your server manually. 1. **Authenticate Locally:** Run the app on your PC first (`python wizard.py`) to generate the `token.json` file (Google Login). @@ -49,14 +51,19 @@ Since secrets and database files shouldn't be in Git, you need to place them on 1. Log in to **Portainer**. 2. Go to **Stacks** > **Add stack**. 3. Select **Repository**. - - **Repository URL:** `` + - **Repository URL:** `http://10.0.0.102:3000/thethreemagi/bookapp.git` - **Compose path:** `docker-compose.yml` -4. Under **Environment variables**, add the following: +4. **Enable Authentication:** Since your Gitea repository is private, you will need to provide credentials. + - Toggle on **Authentication**. + - **Username:** Your Gitea username (`thethreemagi`). + - **Password:** Use a **Personal Access Token** from Gitea, not your actual password. + - In Gitea, go to your **User Settings > Applications** and generate a new token with repository read access. +5. Under **Environment variables**, add the following: - `HOST_PATH`: `/opt/bookapp` (The folder you created in Step 2) - `GEMINI_API_KEY`: `` - `ADMIN_PASSWORD`: `` - `FLASK_SECRET_KEY`: `` -5. Click **Deploy the stack**. +6. Click **Deploy the stack**. Portainer will pull the code from Git, build the image, and mount the secrets/data from your server folder. diff --git a/modules/ai.py b/modules/ai.py index ee00587..ebb73fe 100644 --- a/modules/ai.py +++ b/modules/ai.py @@ -166,6 +166,19 @@ def init_models(force=False): img_source = "Gemini API" if model_image else "None" + # Auto-detect GCP Project from credentials if not set (Fix for Image Model) + if HAS_VERTEX and not config.GCP_PROJECT and config.GOOGLE_CREDS and os.path.exists(config.GOOGLE_CREDS): + try: + with open(config.GOOGLE_CREDS, 'r') as f: + cdata = json.load(f) + # Check common OAuth structures + for k in ['installed', 'web']: + if k in cdata and 'project_id' in cdata[k]: + config.GCP_PROJECT = cdata[k]['project_id'] + utils.log("SYSTEM", f"Auto-detected GCP Project ID: {config.GCP_PROJECT}") + break + except: pass + if HAS_VERTEX and config.GCP_PROJECT: creds = None # Handle OAuth Client ID (credentials.json) if provided instead of Service Account diff --git a/modules/story.py b/modules/story.py index e8c9a49..f32472d 100644 --- a/modules/story.py +++ b/modules/story.py @@ -6,6 +6,71 @@ import config from modules import ai from . import utils +def get_style_guidelines(): + defaults = { + "ai_isms": [ + 'testament to', 'tapestry', 'shiver down spine', 'unspoken agreement', + 'palpable tension', 'a sense of', 'suddenly', 'in that moment', + 'symphony of', 'dance of', 'azure', 'cerulean' + ], + "filter_words": [ + 'felt', 'saw', 'heard', 'realized', 'decided', 'noticed', 'knew', 'thought' + ] + } + path = os.path.join(config.DATA_DIR, "style_guidelines.json") + if os.path.exists(path): + try: + user_data = utils.load_json(path) + if user_data: + if 'ai_isms' in user_data: defaults['ai_isms'] = user_data['ai_isms'] + if 'filter_words' in user_data: defaults['filter_words'] = user_data['filter_words'] + except: pass + else: + try: + with open(path, 'w') as f: json.dump(defaults, f, indent=2) + except: pass + return defaults + +def refresh_style_guidelines(model, folder=None): + utils.log("SYSTEM", "Refreshing Style Guidelines via AI...") + current = get_style_guidelines() + + prompt = f""" + Act as a Literary Editor. Update our 'Banned Words' lists for AI writing. + + CURRENT AI-ISMS (Cliches to avoid): + {json.dumps(current.get('ai_isms', []))} + + CURRENT FILTER WORDS (Distancing language): + {json.dumps(current.get('filter_words', []))} + + TASK: + 1. Review the lists. Remove any that are too common/safe (false positives). + 2. Add new common AI tropes (e.g. 'neon-lit', 'bustling', 'a sense of', 'mined', 'delved'). + 3. Ensure the list is robust but not paralyzing. + + RETURN JSON: {{ "ai_isms": [strings], "filter_words": [strings] }} + """ + try: + response = model.generate_content(prompt) + if folder: utils.log_usage(folder, "logic-pro", response.usage_metadata) + new_data = json.loads(utils.clean_json(response.text)) + + # Validate + if 'ai_isms' in new_data and 'filter_words' in new_data: + path = os.path.join(config.DATA_DIR, "style_guidelines.json") + with open(path, 'w') as f: json.dump(new_data, f, indent=2) + utils.log("SYSTEM", "Style Guidelines updated.") + return new_data + except Exception as e: + utils.log("SYSTEM", f"Failed to refresh guidelines: {e}") + return current + +def filter_characters(chars): + """Removes placeholder characters generated by AI.""" + blacklist = ['name', 'character name', 'role', 'protagonist', 'antagonist', 'love interest', 'unknown', 'tbd', 'todo', 'hero', 'villain', 'main character', 'side character'] + return [c for c in chars if c.get('name') and c.get('name').lower().strip() not in blacklist] + def enrich(bp, folder, context=""): utils.log("ENRICHER", "Fleshing out details from description...") @@ -36,7 +101,7 @@ def enrich(bp, folder, context=""): RETURN JSON in this EXACT format: {{ "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": "Name", "role": "Role", "description": "Description", "key_events": ["Planned injury in Act 2"] }} ], + "characters": [ {{ "name": "John Doe", "role": "Protagonist", "description": "Description", "key_events": ["Planned injury in Act 2"] }} ], "plot_beats": [ "Beat 1", "Beat 2", "..." ] }} """ @@ -72,6 +137,11 @@ def enrich(bp, folder, context=""): if 'characters' not in bp or not bp['characters']: bp['characters'] = ai_data.get('characters', []) + + # Filter out default names + if 'characters' in bp: + bp['characters'] = filter_characters(bp['characters']) + if 'plot_beats' not in bp or not bp['plot_beats']: bp['plot_beats'] = ai_data.get('plot_beats', []) @@ -281,26 +351,45 @@ def update_tracking(folder, chapter_num, chapter_text, current_tracking): return current_tracking def evaluate_chapter_quality(text, chapter_title, 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]]) + prompt = f""" - Analyze this book chapter text. + Act as a World-Class Literary Editor (e.g., Maxwell Perkins). Analyze this chapter draft with extreme scrutiny. CHAPTER TITLE: {chapter_title} + STRICT PROHIBITIONS (Automatic deduction): + - "AI-isms": '{ai_isms}'. + - Filter Words: {fw_examples}, etc. (Show the sensation/action, don't state the internal process). + - Stilted Dialogue: Characters speaking in perfect paragraphs without interruptions, slang, or subtext. + - White Room Syndrome: Dialogue occurring in a void without interaction with the setting/props. + - "As You Know, Bob": Characters explaining things to each other that they both already know. + CRITERIA: - 1. ORGANIC FEEL: Does it sound like a human wrote it? Are "AI-isms" (e.g. 'testament to', 'tapestry', 'shiver down spine', 'unspoken agreement') absent? - 2. ENGAGEMENT: Is it interesting? Does it hook the reader? - 3. REPETITION: Is sentence structure varied? Are words repeated unnecessarily? - 4. PROGRESSION: Does the story move forward, or is it spinning its wheels? + 1. VOICE & TONE: Is the narrative voice distinct, or generic? Does it match the genre? + 2. SHOW, DON'T TELL: Are emotions demonstrated through action/viscera, or summarized? + 3. PACING: Does the scene drag? Is there conflict in every beat? + 4. CHARACTER AGENCY: Do characters make choices, or do things just happen to them? - Rate on a scale of 1-10. - Provide a concise critique focusing on the biggest flaw. + Rate on a scale of 1-10. (Be harsh. 10 is Pulitzer level. 6 is average. Anything below 8 needs work). - Return JSON: {{'score': int, 'critique': 'string'}} + Return JSON: {{ + 'score': int, + 'critique': 'Detailed analysis of flaws, citing specific examples from the text.', + 'actionable_feedback': 'List of 3-5 specific, ruthless instructions for the rewrite (e.g. "Cut the first 3 paragraphs", "Make the dialogue in the middle argument more aggressive", "Describe the smell of the room").' + }} """ try: response = model.generate_content([prompt, text[:30000]]) utils.log_usage(folder, "logic-pro", response.usage_metadata) data = json.loads(utils.clean_json(response.text)) - return data.get('score', 0), data.get('critique', 'No critique provided.') + + 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)}" @@ -336,7 +425,7 @@ def refine_persona(bp, text, folder): current_bio = ad.get('bio', 'Standard style.') prompt = f""" - Analyze this text sample from the book. + Act as a Literary Stylist. Analyze this text sample from the book. TEXT: {text[:3000]} @@ -449,7 +538,7 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None): - 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. - SENSORY DETAILS: Use specific, grounding sensory details (smell, touch, sound) rather than generic descriptions. - - AVOID CLICHÉS: Avoid common AI tropes (e.g., 'shiver down spine', 'palpable tension', 'unspoken agreement', 'testament to'). + - 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. @@ -477,14 +566,17 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None): return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}" # Refinement Loop - max_attempts = 3 + max_attempts = 9 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'], ai.model_logic, 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 @@ -505,17 +597,27 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None): return best_text 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) + refine_prompt = f""" Act as a Senior Editor. Rewrite this chapter to fix the issues identified below. - CRITIQUE TO ADDRESS: + CRITIQUE TO ADDRESS (MANDATORY): {critique} - ADDITIONAL OBJECTIVES: - 1. NATURAL FLOW: Fix stilted phrasing. Ensure the prose flows naturally for the genre ({meta.get('genre', 'Fiction')}) and tone ({style.get('tone', 'Standard')}). - 2. HUMANIZATION: Remove robotic phrasing. Ensure dialogue has subtext, interruptions, and distinct voices. Remove "AI-isms" (e.g. 'testament to', 'tapestry of', 'symphony of'). - 3. SENTENCE VARIETY: Check for and fix repetitive sentence starts or uniform sentence lengths. The prose should have a dynamic rhythm. - 4. CONTINUITY: Ensure consistency with the Story So Far. + PREVIOUS CRITIQUES (Reference): + {history_str} + + STYLIZED REWRITE INSTRUCTIONS: + 1. REMOVE FILTER WORDS: Delete "{fw_list}". Describe the image, sensation, or sound directly. + 2. VARY SENTENCE STRUCTURE: Do not start consecutive sentences with "He", "She", or "The". Use introductory clauses and varying lengths. + 3. SUBTEXT: Ensure dialogue implies meaning rather than stating it outright. People rarely say exactly what they mean. + 4. GENRE CONSISTENCY: Ensure the tone matches {meta.get('genre', 'Fiction')}. + 5. SETTING INTERACTION: Ensure characters interact with their environment (props, weather, lighting) during dialogue. STORY SO FAR: {prev_sum} @@ -544,9 +646,11 @@ def harvest_metadata(bp, folder, full_manuscript): response = ai.model_logic.generate_content(prompt) utils.log_usage(folder, "logic-pro", response.usage_metadata) new_chars = json.loads(utils.clean_json(response.text)).get('new_characters', []) - if new_chars: - utils.log("HARVESTER", f"Found {len(new_chars)} new chars.") - bp['characters'].extend(new_chars) + 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 @@ -609,11 +713,13 @@ def update_persona_sample(bp, folder): def refine_bible(bible, instruction, folder): utils.log("SYSTEM", f"Refining Bible with instruction: {instruction}") prompt = f""" - Act as a Book Editor. + Act as a Senior Developmental Editor. CURRENT JSON: {json.dumps(bible)} USER INSTRUCTION: {instruction} TASK: Update the JSON based on the instruction. Maintain valid JSON structure. + Ensure character motivations remain consistent and plot holes are avoided. + RETURN ONLY THE JSON. """ try: diff --git a/modules/web_app.py b/modules/web_app.py index 4fc85a5..571b524 100644 --- a/modules/web_app.py +++ b/modules/web_app.py @@ -391,6 +391,7 @@ def view_project(id): other_projects = Project.query.filter(Project.user_id == current_user.id, Project.id != id).all() artifacts = [] + cover_image = None generated_books = {} # Map book_number -> {status: 'generated', run_id: int, folder: str} # Scan ALL completed runs to find the latest status of each book @@ -409,13 +410,28 @@ def view_project(id): b_num = int(parts[1]) # Only add if we haven't found a newer version (runs are ordered desc) if b_num not in generated_books: - generated_books[b_num] = {'status': 'generated', 'run_id': r.id, 'folder': d} + # Find artifacts for direct download link + book_path = os.path.join(run_dir, d) + epub_file = next((f for f in os.listdir(book_path) if f.endswith('.epub')), None) + docx_file = next((f for f in os.listdir(book_path) if f.endswith('.docx')), None) + + generated_books[b_num] = {'status': 'generated', 'run_id': r.id, 'folder': d, 'epub': os.path.join(d, epub_file).replace("\\", "/") if epub_file else None, 'docx': os.path.join(d, docx_file).replace("\\", "/") if docx_file else None} except: pass # Collect Artifacts from Latest Run if latest_run: run_dir = os.path.join(proj.folder_path, "runs", "bible", f"run_{latest_run.id}") if os.path.exists(run_dir): + # Find Cover Image (Root or First Book) + if os.path.exists(os.path.join(run_dir, "cover.png")): + cover_image = "cover.png" + else: + subdirs = sorted([d for d in os.listdir(run_dir) if os.path.isdir(os.path.join(run_dir, d)) and d.startswith("Book_")]) + 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')): @@ -426,7 +442,7 @@ def view_project(id): 'type': f.split('.')[-1].upper() }) - return render_template('project.html', project=proj, bible=bible_data, runs=runs, active_run=latest_run, artifacts=artifacts, personas=personas, generated_books=generated_books, other_projects=other_projects) + 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) @app.route('/project//run', methods=['POST']) @login_required @@ -718,18 +734,31 @@ def view_run(id): # Fetch Artifacts for Display run_dir = os.path.join(run.project.folder_path, "runs", "bible", f"run_{run.id}") - # Detect Book Subfolder (Series Support) - book_dir = run_dir + # Detect Books in Run (Series Support) + books_data = [] if os.path.exists(run_dir): subdirs = sorted([d for d in os.listdir(run_dir) if os.path.isdir(os.path.join(run_dir, d)) and d.startswith("Book_")]) - if subdirs: book_dir = os.path.join(run_dir, subdirs[0]) - - blurb_content = "" - blurb_path = os.path.join(book_dir, "blurb.txt") - if os.path.exists(blurb_path): - with open(blurb_path, 'r', encoding='utf-8', errors='ignore') as f: blurb_content = f.read() + if not subdirs: subdirs = ["."] # Handle legacy/flat runs + + for d in subdirs: + b_path = os.path.join(run_dir, d) + b_info = {'folder': d, 'artifacts': [], 'cover': None, 'blurb': ''} - has_cover = os.path.exists(os.path.join(book_dir, "cover.png")) + # Artifacts + for f in os.listdir(b_path): + if f.lower().endswith(('.epub', '.docx')): + b_info['artifacts'].append({'name': f, 'path': os.path.join(d, f).replace("\\", "/")}) + + # Cover + if os.path.exists(os.path.join(b_path, "cover.png")): + b_info['cover'] = os.path.join(d, "cover.png").replace("\\", "/") + + # Blurb + blurb_p = os.path.join(b_path, "blurb.txt") + if os.path.exists(blurb_p): + with open(blurb_p, 'r', encoding='utf-8', errors='ignore') as f: b_info['blurb'] = f.read() + + books_data.append(b_info) # Load Bible Data for Dropdown bible_path = os.path.join(run.project.folder_path, "bible.json") @@ -737,6 +766,8 @@ def view_run(id): # Load Tracking Data for Run Details tracking = {"events": [], "characters": {}, "content_warnings": []} + # We load tracking from the first book found to populate the general stats + book_dir = os.path.join(run_dir, books_data[0]['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") @@ -748,7 +779,7 @@ def view_run(id): if os.path.exists(t_wn): tracking['content_warnings'] = utils.load_json(t_wn) or [] # Use dedicated run details template - return render_template('run_details.html', run=run, log_content=log_content, blurb=blurb_content, has_cover=has_cover, bible=bible_data, tracking=tracking) + return render_template('run_details.html', run=run, log_content=log_content, books=books_data, bible=bible_data, tracking=tracking) @app.route('/run//status') @login_required @@ -817,7 +848,12 @@ def optimize_models(): # Force refresh via AI module (safely handles failures) try: ai.init_models(force=True) # Force re-initialization and API scan - flash("AI Models refreshed and optimized.") + + # Refresh Style Guidelines + if ai.model_logic: + story.refresh_style_guidelines(ai.model_logic) + + flash("AI Models refreshed and Style Guidelines updated.") except Exception as e: flash(f"Error refreshing models: {e}") @@ -1080,6 +1116,29 @@ def admin_spend_report(): return render_template('admin_spend.html', report=report, days=days, total=total_period_spend) +@app.route('/admin/style', methods=['GET', 'POST']) +@login_required +@admin_required +def admin_style_guidelines(): + path = os.path.join(config.DATA_DIR, "style_guidelines.json") + + if request.method == 'POST': + ai_isms_raw = request.form.get('ai_isms', '') + filter_words_raw = request.form.get('filter_words', '') + + data = { + "ai_isms": [x.strip() for x in ai_isms_raw.split('\n') if x.strip()], + "filter_words": [x.strip() for x in filter_words_raw.split('\n') if x.strip()] + } + + with open(path, 'w') as f: json.dump(data, f, indent=2) + flash("Style Guidelines updated successfully.") + return redirect(url_for('admin_style_guidelines')) + + # Load current (creates defaults if missing) + data = story.get_style_guidelines() + return render_template('admin_style.html', data=data) + @app.route('/admin/impersonate/') @login_required @admin_required diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index b56e5fe..7439686 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -61,6 +61,16 @@
+
+
+
Configuration
+
+
+

Manage global AI writing rules and banned words.

+ Edit Style Guidelines +
+
+
System Stats
diff --git a/templates/admin_style.html b/templates/admin_style.html new file mode 100644 index 0000000..7b1d003 --- /dev/null +++ b/templates/admin_style.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+

Style Guidelines

+ Back to Admin +
+ +
+
+

+ These lists are used by the Editor Persona to critique chapters and by the Writer to refine text. + The AI will be penalized for using words in these lists. +

+ +
+
+ +
Common tropes that make text sound robotic (e.g., "testament to", "tapestry"). One per line.
+ +
+ +
+ +
Words that create distance between the reader and the POV (e.g., "felt", "saw", "realized"). One per line.
+ +
+ +
+ + +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/project.html b/templates/project.html index f364b42..5943339 100644 --- a/templates/project.html +++ b/templates/project.html @@ -197,7 +197,16 @@
Book {{ book.book_number }} {% if generated_books.get(book.book_number) %} - Generated +
+ + +
{% else %} Planned {% endif %} diff --git a/templates/run_details.html b/templates/run_details.html index 8c1ee75..2132955 100644 --- a/templates/run_details.html +++ b/templates/run_details.html @@ -109,54 +109,64 @@
-
- -
-
-
-
Cover Art
+ +{% for book in books %} +
+
+
{{ book.folder }}
+
+
+
+ +
+
+ {% if book.cover %} + Book Cover + {% else %} +
+
No cover. +
+ {% endif %} + + {% if loop.first %} +
+ + +
+ {% endif %} +
-
- {% if has_cover %} - Book Cover - {% else %} -
-
- No cover generated yet. -
- {% endif %} + + +
+
Back Cover Blurb
+
+ {% if book.blurb %} +

{{ book.blurb }}

+ {% else %} +

No blurb generated.

+ {% endif %} +
-
- -
- - - -
+
Artifacts
+
+ {% for art in book.artifacts %} + + {{ art.name }} + + {% else %} + No files found. + {% endfor %} +
+
+{% endfor %} - -
- -
-
-
Blurb
-
-
- {% if blurb %} -

{{ blurb }}

- {% else %} -

Blurb not generated yet.

- {% endif %} -
-
- - -
+
diff --git a/wizard.py b/wizard.py index aad46fd..af0c3da 100644 --- a/wizard.py +++ b/wizard.py @@ -529,6 +529,8 @@ class BookWizard: if new_data: if 'characters' in new_data: self.data['characters'] = new_data['characters'] + # Filter defaults + self.data['characters'] = [c for c in self.data['characters'] if c.get('name') and c.get('name').lower() not in ['name', 'character name', 'role', 'protagonist', 'unknown']] if 'books' in new_data: # Merge book data carefully