Fixes for site

This commit is contained in:
2026-02-03 13:49:49 -05:00
parent 403d015dbe
commit c2e7ed01b4
9 changed files with 346 additions and 82 deletions

View File

@@ -29,10 +29,12 @@ This is the best way to run the Web Dashboard on a server using Portainer.
### 1. Git Setup ### 1. Git Setup
1. Create a new Git repository (GitHub/GitLab). 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. 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. - **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) ### 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. 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). 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**. 1. Log in to **Portainer**.
2. Go to **Stacks** > **Add stack**. 2. Go to **Stacks** > **Add stack**.
3. Select **Repository**. 3. Select **Repository**.
- **Repository URL:** `<your-git-repo-url>` - **Repository URL:** `http://10.0.0.102:3000/thethreemagi/bookapp.git`
- **Compose path:** `docker-compose.yml` - **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) - `HOST_PATH`: `/opt/bookapp` (The folder you created in Step 2)
- `GEMINI_API_KEY`: `<your-api-key>` - `GEMINI_API_KEY`: `<your-api-key>`
- `ADMIN_PASSWORD`: `<secure-password-for-web-ui>` - `ADMIN_PASSWORD`: `<secure-password-for-web-ui>`
- `FLASK_SECRET_KEY`: `<random-string>` - `FLASK_SECRET_KEY`: `<random-string>`
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. Portainer will pull the code from Git, build the image, and mount the secrets/data from your server folder.

View File

@@ -166,6 +166,19 @@ def init_models(force=False):
img_source = "Gemini API" if model_image else "None" 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: if HAS_VERTEX and config.GCP_PROJECT:
creds = None creds = None
# Handle OAuth Client ID (credentials.json) if provided instead of Service Account # Handle OAuth Client ID (credentials.json) if provided instead of Service Account

View File

@@ -6,6 +6,71 @@ import config
from modules import ai from modules import ai
from . import utils 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=""): def enrich(bp, folder, context=""):
utils.log("ENRICHER", "Fleshing out details from description...") utils.log("ENRICHER", "Fleshing out details from description...")
@@ -36,7 +101,7 @@ def enrich(bp, folder, context=""):
RETURN JSON in this EXACT format: 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"] }} }}, "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", "..." ] "plot_beats": [ "Beat 1", "Beat 2", "..." ]
}} }}
""" """
@@ -72,6 +137,11 @@ def enrich(bp, folder, context=""):
if 'characters' not in bp or not bp['characters']: if 'characters' not in bp or not bp['characters']:
bp['characters'] = ai_data.get('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']: if 'plot_beats' not in bp or not bp['plot_beats']:
bp['plot_beats'] = ai_data.get('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 return current_tracking
def evaluate_chapter_quality(text, chapter_title, model, folder): 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""" 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} 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: 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? 1. VOICE & TONE: Is the narrative voice distinct, or generic? Does it match the genre?
2. ENGAGEMENT: Is it interesting? Does it hook the reader? 2. SHOW, DON'T TELL: Are emotions demonstrated through action/viscera, or summarized?
3. REPETITION: Is sentence structure varied? Are words repeated unnecessarily? 3. PACING: Does the scene drag? Is there conflict in every beat?
4. PROGRESSION: Does the story move forward, or is it spinning its wheels? 4. CHARACTER AGENCY: Do characters make choices, or do things just happen to them?
Rate on a scale of 1-10. Rate on a scale of 1-10. (Be harsh. 10 is Pulitzer level. 6 is average. Anything below 8 needs work).
Provide a concise critique focusing on the biggest flaw.
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: try:
response = model.generate_content([prompt, text[:30000]]) response = model.generate_content([prompt, text[:30000]])
utils.log_usage(folder, "logic-pro", response.usage_metadata) utils.log_usage(folder, "logic-pro", response.usage_metadata)
data = json.loads(utils.clean_json(response.text)) 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: except Exception as e:
return 0, f"Evaluation error: {str(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.') current_bio = ad.get('bio', 'Standard style.')
prompt = f""" prompt = f"""
Analyze this text sample from the book. Act as a Literary Stylist. Analyze this text sample from the book.
TEXT: TEXT:
{text[:3000]} {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. - 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. - 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. - 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. - 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. - 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. - 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}" return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}"
# Refinement Loop # Refinement Loop
max_attempts = 3 max_attempts = 9
best_score = 0 best_score = 0
best_text = current_text best_text = current_text
past_critiques = []
for attempt in range(1, max_attempts + 1): for attempt in range(1, max_attempts + 1):
utils.log("WRITER", f" -> Evaluating Ch {chap['chapter_number']} (Attempt {attempt}/{max_attempts})...") 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) 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: if "Evaluation error" in critique:
utils.log("WRITER", f" ⚠️ {critique}. Keeping current draft.") utils.log("WRITER", f" ⚠️ {critique}. Keeping current draft.")
if best_score == 0: best_text = current_text 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 return best_text
utils.log("WRITER", f" -> Refining Ch {chap['chapter_number']} based on feedback...") 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""" refine_prompt = f"""
Act as a Senior Editor. Rewrite this chapter to fix the issues identified below. Act as a Senior Editor. Rewrite this chapter to fix the issues identified below.
CRITIQUE TO ADDRESS: CRITIQUE TO ADDRESS (MANDATORY):
{critique} {critique}
ADDITIONAL OBJECTIVES: PREVIOUS CRITIQUES (Reference):
1. NATURAL FLOW: Fix stilted phrasing. Ensure the prose flows naturally for the genre ({meta.get('genre', 'Fiction')}) and tone ({style.get('tone', 'Standard')}). {history_str}
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. STYLIZED REWRITE INSTRUCTIONS:
4. CONTINUITY: Ensure consistency with the Story So Far. 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: STORY SO FAR:
{prev_sum} {prev_sum}
@@ -545,8 +647,10 @@ def harvest_metadata(bp, folder, full_manuscript):
utils.log_usage(folder, "logic-pro", response.usage_metadata) utils.log_usage(folder, "logic-pro", response.usage_metadata)
new_chars = json.loads(utils.clean_json(response.text)).get('new_characters', []) new_chars = json.loads(utils.clean_json(response.text)).get('new_characters', [])
if new_chars: if new_chars:
utils.log("HARVESTER", f"Found {len(new_chars)} new chars.") valid_chars = filter_characters(new_chars)
bp['characters'].extend(new_chars) if valid_chars:
utils.log("HARVESTER", f"Found {len(valid_chars)} new chars.")
bp['characters'].extend(valid_chars)
except: pass except: pass
return bp return bp
@@ -609,11 +713,13 @@ def update_persona_sample(bp, folder):
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"""
Act as a Book Editor. Act as a Senior Developmental Editor.
CURRENT JSON: {json.dumps(bible)} CURRENT JSON: {json.dumps(bible)}
USER INSTRUCTION: {instruction} USER INSTRUCTION: {instruction}
TASK: Update the JSON based on the instruction. Maintain valid JSON structure. 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. RETURN ONLY THE JSON.
""" """
try: try:

View File

@@ -391,6 +391,7 @@ def view_project(id):
other_projects = Project.query.filter(Project.user_id == current_user.id, Project.id != id).all() other_projects = Project.query.filter(Project.user_id == current_user.id, Project.id != id).all()
artifacts = [] artifacts = []
cover_image = None
generated_books = {} # Map book_number -> {status: 'generated', run_id: int, folder: str} generated_books = {} # Map book_number -> {status: 'generated', run_id: int, folder: str}
# Scan ALL completed runs to find the latest status of each book # 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]) b_num = int(parts[1])
# Only add if we haven't found a newer version (runs are ordered desc) # Only add if we haven't found a newer version (runs are ordered desc)
if b_num not in generated_books: 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 except: pass
# Collect Artifacts from Latest Run # Collect Artifacts from Latest Run
if latest_run: if latest_run:
run_dir = os.path.join(proj.folder_path, "runs", "bible", f"run_{latest_run.id}") run_dir = os.path.join(proj.folder_path, "runs", "bible", f"run_{latest_run.id}")
if os.path.exists(run_dir): 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 root, dirs, files in os.walk(run_dir):
for f in files: for f in files:
if f.lower().endswith(('.epub', '.docx')): if f.lower().endswith(('.epub', '.docx')):
@@ -426,7 +442,7 @@ def view_project(id):
'type': f.split('.')[-1].upper() '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/<int:id>/run', methods=['POST']) @app.route('/project/<int:id>/run', methods=['POST'])
@login_required @login_required
@@ -718,18 +734,31 @@ def view_run(id):
# Fetch Artifacts for Display # Fetch Artifacts for Display
run_dir = os.path.join(run.project.folder_path, "runs", "bible", f"run_{run.id}") run_dir = os.path.join(run.project.folder_path, "runs", "bible", f"run_{run.id}")
# Detect Book Subfolder (Series Support) # Detect Books in Run (Series Support)
book_dir = run_dir books_data = []
if os.path.exists(run_dir): 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_")]) 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]) if not subdirs: subdirs = ["."] # Handle legacy/flat runs
blurb_content = "" for d in subdirs:
blurb_path = os.path.join(book_dir, "blurb.txt") b_path = os.path.join(run_dir, d)
if os.path.exists(blurb_path): b_info = {'folder': d, 'artifacts': [], 'cover': None, 'blurb': ''}
with open(blurb_path, 'r', encoding='utf-8', errors='ignore') as f: blurb_content = f.read()
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 # Load Bible Data for Dropdown
bible_path = os.path.join(run.project.folder_path, "bible.json") 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 # Load Tracking Data for Run Details
tracking = {"events": [], "characters": {}, "content_warnings": []} 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): if os.path.exists(book_dir):
t_ev = os.path.join(book_dir, "tracking_events.json") t_ev = os.path.join(book_dir, "tracking_events.json")
t_ch = os.path.join(book_dir, "tracking_characters.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 [] if os.path.exists(t_wn): tracking['content_warnings'] = utils.load_json(t_wn) or []
# Use dedicated run details template # 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/<int:id>/status') @app.route('/run/<int:id>/status')
@login_required @login_required
@@ -817,7 +848,12 @@ def optimize_models():
# Force refresh via AI module (safely handles failures) # Force refresh via AI module (safely handles failures)
try: try:
ai.init_models(force=True) # Force re-initialization and API scan 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: except Exception as e:
flash(f"Error refreshing models: {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) 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/<int:user_id>') @app.route('/admin/impersonate/<int:user_id>')
@login_required @login_required
@admin_required @admin_required

View File

@@ -61,6 +61,16 @@
<!-- System Stats & Reset --> <!-- System Stats & Reset -->
<div class="col-md-6 mb-4"> <div class="col-md-6 mb-4">
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="fas fa-sliders-h me-2"></i>Configuration</h5>
</div>
<div class="card-body">
<p class="text-muted small">Manage global AI writing rules and banned words.</p>
<a href="{{ url_for('admin_style_guidelines') }}" class="btn btn-outline-primary w-100"><i class="fas fa-spell-check me-2"></i>Edit Style Guidelines</a>
</div>
</div>
<div class="card shadow-sm mb-4"> <div class="card shadow-sm mb-4">
<div class="card-header bg-light"> <div class="card-header bg-light">
<h5 class="mb-0"><i class="fas fa-chart-pie me-2"></i>System Stats</h5> <h5 class="mb-0"><i class="fas fa-chart-pie me-2"></i>System Stats</h5>

View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="fas fa-spell-check me-2 text-primary"></i>Style Guidelines</h2>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-outline-secondary">Back to Admin</a>
</div>
<div class="card shadow-sm">
<div class="card-body">
<p class="text-muted">
These lists are used by the <strong>Editor Persona</strong> to critique chapters and by the <strong>Writer</strong> to refine text.
The AI will be penalized for using words in these lists.
</p>
<form method="POST">
<div class="mb-4">
<label class="form-label fw-bold text-danger">
<i class="fas fa-ban me-2"></i>Banned "AI-isms" & Clichés
</label>
<div class="form-text mb-2">Common tropes that make text sound robotic (e.g., "testament to", "tapestry"). One per line.</div>
<textarea name="ai_isms" class="form-control font-monospace" rows="10">{{ data.ai_isms|join('\n') }}</textarea>
</div>
<div class="mb-4">
<label class="form-label fw-bold text-warning">
<i class="fas fa-filter me-2"></i>Filter Words
</label>
<div class="form-text mb-2">Words that create distance between the reader and the POV (e.g., "felt", "saw", "realized"). One per line.</div>
<textarea name="filter_words" class="form-control font-monospace" rows="6">{{ data.filter_words|join('\n') }}</textarea>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-save me-2"></i>Save Guidelines
</button>
<button type="submit" formaction="{{ url_for('optimize_models') }}" class="btn btn-outline-info w-100 mt-2">
<i class="fas fa-magic me-2"></i>Auto-Refresh with AI
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -197,7 +197,16 @@
<div class="d-flex justify-content-between align-items-start mb-2"> <div class="d-flex justify-content-between align-items-start mb-2">
<span class="badge bg-light text-dark border">Book {{ book.book_number }}</span> <span class="badge bg-light text-dark border">Book {{ book.book_number }}</span>
{% if generated_books.get(book.book_number) %} {% if generated_books.get(book.book_number) %}
<span class="badge bg-success"><i class="fas fa-check me-1"></i>Generated</span> <div class="btn-group">
<button type="button" class="btn btn-sm btn-success dropdown-toggle" data-bs-toggle="dropdown">
<i class="fas fa-check me-1"></i>Generated
</button>
<ul class="dropdown-menu">
{% set gb = generated_books.get(book.book_number) %}
{% if gb.epub %}<li><a class="dropdown-item" href="/project/{{ gb.run_id }}/download?file={{ gb.epub }}"><i class="fas fa-file-epub me-2"></i>Download EPUB</a></li>{% endif %}
{% if gb.docx %}<li><a class="dropdown-item" href="/project/{{ gb.run_id }}/download?file={{ gb.docx }}"><i class="fas fa-file-word me-2"></i>Download DOCX</a></li>{% endif %}
</ul>
</div>
{% else %} {% else %}
<span class="badge bg-secondary">Planned</span> <span class="badge bg-secondary">Planned</span>
{% endif %} {% endif %}

View File

@@ -109,54 +109,64 @@
</div> </div>
</div> </div>
<div class="row"> <!-- Generated Books in this Run -->
<!-- Left Column: Cover Art --> {% for book in books %}
<div class="col-md-4 mb-4"> <div class="card shadow-sm mb-4">
<div class="card shadow-sm h-100"> <div class="card-header bg-light">
<div class="card-header bg-light"> <h5 class="mb-0"><i class="fas fa-book me-2"></i>{{ book.folder }}</h5>
<h5 class="mb-0"><i class="fas fa-image me-2"></i>Cover Art</h5> </div>
<div class="card-body">
<div class="row">
<!-- Left Column: Cover Art -->
<div class="col-md-4 mb-3">
<div class="text-center">
{% if book.cover %}
<img src="{{ url_for('download_artifact', run_id=run.id, file=book.cover) }}" class="img-fluid rounded shadow-sm mb-3" alt="Book Cover" style="max-height: 400px;">
{% else %}
<div class="alert alert-secondary py-5">
<i class="fas fa-image fa-3x mb-3"></i><br>No cover.
</div>
{% endif %}
{% if loop.first %}
<form action="{{ url_for('regenerate_artifacts', run_id=run.id) }}" method="POST" class="mt-2">
<textarea name="feedback" class="form-control mb-2 form-control-sm" rows="1" placeholder="Cover Feedback..."></textarea>
<button type="submit" class="btn btn-sm btn-outline-primary w-100">
<i class="fas fa-sync me-2"></i>Regenerate All
</button>
</form>
{% endif %}
</div>
</div> </div>
<div class="card-body text-center">
{% if has_cover %}
<img src="{{ url_for('download_artifact', run_id=run.id, file='cover.png') }}" class="img-fluid rounded shadow-sm mb-3" alt="Book Cover">
{% else %}
<div class="alert alert-secondary py-5">
<i class="fas fa-image fa-3x mb-3"></i><br>
No cover generated yet.
</div>
{% endif %}
<hr> <!-- Right Column: Blurb -->
<div class="col-md-8">
<h6 class="fw-bold">Back Cover Blurb</h6>
<div class="p-3 bg-light rounded mb-3">
{% if book.blurb %}
<p class="mb-0" style="white-space: pre-wrap;">{{ book.blurb }}</p>
{% else %}
<p class="text-muted fst-italic mb-0">No blurb generated.</p>
{% endif %}
</div>
<form action="{{ url_for('regenerate_artifacts', run_id=run.id) }}" method="POST"> <h6 class="fw-bold">Artifacts</h6>
<label class="form-label text-start w-100 small fw-bold">Regenerate Art & Files</label> <div class="d-flex flex-wrap gap-2">
<textarea name="feedback" class="form-control mb-2" rows="2" placeholder="Feedback (e.g. 'Make the font larger', 'Use a darker theme')..."></textarea> {% for art in book.artifacts %}
<button type="submit" class="btn btn-primary w-100"> <a href="{{ url_for('download_artifact', run_id=run.id, file=art.path) }}" class="btn btn-sm btn-outline-success">
<i class="fas fa-sync me-2"></i>Regenerate <i class="fas fa-download me-1"></i> {{ art.name }}
</button> </a>
</form> {% else %}
<span class="text-muted small">No files found.</span>
{% endfor %}
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
{% endfor %}
<!-- Right Column: Blurb & Stats --> <div class="row mb-4">
<div class="col-md-8 mb-4">
<!-- Blurb -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="fas fa-align-left me-2"></i>Blurb</h5>
</div>
<div class="card-body">
{% if blurb %}
<p class="card-text" style="white-space: pre-wrap;">{{ blurb }}</p>
{% else %}
<p class="text-muted fst-italic">Blurb not generated yet.</p>
{% endif %}
</div>
</div>
<!-- Stats -->
<div class="row mb-4">
<div class="col-6"> <div class="col-6">
<div class="card shadow-sm text-center"> <div class="card shadow-sm text-center">
<div class="card-body"> <div class="card-body">

View File

@@ -529,6 +529,8 @@ 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']]
if 'books' in new_data: if 'books' in new_data:
# Merge book data carefully # Merge book data carefully